diff --git a/frontend/app/(dashboard)/assets/[id]/page.tsx b/frontend/app/(dashboard)/assets/[id]/page.tsx
index 3333bbbf..4890b274 100644
--- a/frontend/app/(dashboard)/assets/[id]/page.tsx
+++ b/frontend/app/(dashboard)/assets/[id]/page.tsx
@@ -1,787 +1 @@
-// frontend/app/(dashboard)/assets/[id]/page.tsx
-"use client";
-
-import { useState, useEffect } from "react";
-import { useParams, useRouter } from "next/navigation";
-import {
- ArrowLeft,
- Clock,
- FileText,
- Hash,
- Wrench,
- FolderOpen,
- StickyNote,
- Pencil,
- Trash2,
- ArrowRightLeft,
- RefreshCw,
- CheckCircle,
- Upload,
- Plus,
- Printer,
- QrCode,
-} from "lucide-react";
-import { format } from "date-fns";
-import { Button } from "@/components/ui/button";
-import { StatusBadge } from "@/components/assets/status-badge";
-import { ConditionBadge } from "@/components/assets/condition-badge";
-import { ConfirmDialog } from "@/components/ui/confirm-dialog";
-import {
- useAsset,
- useAssetHistory,
- useAssetDocuments,
- useMaintenanceRecords,
- useAssetNotes,
- useDeleteAsset,
- useUploadDocument,
- useDeleteDocument,
- useCreateMaintenanceRecord,
- useUpdateMaintenanceStatus,
- useCreateNote,
- useDeleteNote,
-} from "@/lib/query/hooks/useAsset";
-import type { MaintenanceType } from "@/lib/query/types/asset";
-
-type Tab = "overview" | "history" | "maintenance" | "documents" | "notes";
-
-// ── Skeleton ────────────────────────────────────────────────────────────────
-function Skeleton({ className }: { className?: string }) {
- return (
-
- );
-}
-
-// ── DetailRow ────────────────────────────────────────────────────────────────
-function DetailRow({
- label,
- value,
- fallback = "—",
-}: {
- label: string;
- value?: string | null;
- fallback?: string;
-}) {
- return (
-
-
{label}
- {value || fallback}
-
- );
-}
-
-// ── ActionBadge ──────────────────────────────────────────────────────────────
-const actionColors: Record = {
- CREATED: "bg-green-100 text-green-700",
- UPDATED: "bg-blue-100 text-blue-700",
- STATUS_CHANGED: "bg-yellow-100 text-yellow-700",
- TRANSFERRED: "bg-purple-100 text-purple-700",
- MAINTENANCE: "bg-orange-100 text-orange-700",
- NOTE_ADDED: "bg-gray-100 text-gray-600",
- DOCUMENT_UPLOADED: "bg-teal-100 text-teal-700",
-};
-
-function ActionBadge({ action }: { action: string }) {
- const cls = actionColors[action] ?? "bg-gray-100 text-gray-600";
- return (
-
- {action.replace(/_/g, " ")}
-
- );
-}
-
-// ── MaintenanceStatusBadge ───────────────────────────────────────────────────
-const maintenanceStatusColors: Record = {
- SCHEDULED: "bg-blue-100 text-blue-700",
- IN_PROGRESS: "bg-yellow-100 text-yellow-700",
- COMPLETED: "bg-green-100 text-green-700",
- CANCELLED: "bg-gray-100 text-gray-500",
-};
-
-function MaintenanceStatusBadge({ status }: { status: string }) {
- const cls = maintenanceStatusColors[status] ?? "bg-gray-100 text-gray-500";
- return (
-
- {status.replace(/_/g, " ")}
-
- );
-}
-
-// ── ScheduleMaintenanceModal ─────────────────────────────────────────────────
-function ScheduleMaintenanceModal({
- assetId,
- onClose,
-}: {
- assetId: string;
- onClose: () => void;
-}) {
- const [form, setForm] = useState({
- type: "PREVENTIVE" as MaintenanceType,
- description: "",
- scheduledDate: "",
- notes: "",
- });
- const { mutate, isPending } = useCreateMaintenanceRecord(assetId, {
- onSuccess: onClose,
- });
-
- return (
-
-
-
-
- Schedule Maintenance
-
-
-
-
-
-
-
-
- setForm({ ...form, description: e.target.value })}
- />
-
-
-
- setForm({ ...form, scheduledDate: e.target.value })}
- />
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// ── UploadDocumentModal ──────────────────────────────────────────────────────
-function UploadDocumentModal({
- assetId,
- onClose,
-}: {
- assetId: string;
- onClose: () => void;
-}) {
- const [file, setFile] = useState(null);
- const [name, setName] = useState("");
- const { mutate: upload, isPending: uploading } = useUploadDocument(assetId, {
- onSuccess: onClose,
- });
-
- return (
-
-
-
-
Upload Document
-
-
-
-
-
-
-
- );
-}
-
-// ── Main Page ────────────────────────────────────────────────────────────────
-export default function AssetDetailPage() {
- const { id } = useParams<{ id: string }>();
- const router = useRouter();
- const [tab, setTab] = useState("overview");
- const [qrCodeDataUri, setQrCodeDataUri] = useState(null);
-
- // Confirm dialogs
- const [confirmDelete, setConfirmDelete] = useState(false);
- const [confirmDeleteDoc, setConfirmDeleteDoc] = useState(null);
- const [confirmDeleteNote, setConfirmDeleteNote] = useState(null);
-
- // Modals
- const [showScheduleMaintenance, setShowScheduleMaintenance] = useState(false);
- const [showUploadDoc, setShowUploadDoc] = useState(false);
-
- // Note form
- const [noteContent, setNoteContent] = useState("");
-
- // Queries
- const { data: asset, isLoading } = useAsset(id);
- const { data: history = [], isLoading: historyLoading } = useAssetHistory(id);
- const { data: maintenance = [], isLoading: maintenanceLoading } = useMaintenanceRecords(id);
- const { data: documents = [], isLoading: documentsLoading } = useAssetDocuments(id);
- const { data: notes = [], isLoading: notesLoading } = useAssetNotes(id);
-
- // Mutations
- const { mutate: deleteAsset, isPending: deletingAsset } = useDeleteAsset(id, {
- onSuccess: () => router.push("/assets"),
- });
- const { mutate: deleteDoc, isPending: deletingDoc } = useDeleteDocument(id);
- const { mutate: deleteNote, isPending: deletingNote } = useDeleteNote(id);
- const { mutate: markComplete, isPending: markingComplete } = useUpdateMaintenanceStatus(id);
- const { mutate: addNote, isPending: addingNote } = useCreateNote(id, {
- onSuccess: () => setNoteContent(""),
- });
-
- // Fetch QR code when asset loads
- useEffect(() => {
- if (!asset?.id) return;
-
- const fetchQRCode = async () => {
- try {
- const response = await fetch(`/api/assets/${asset.id}/qr`);
- if (response.ok) {
- const blob = await response.blob();
- const reader = new FileReader();
- reader.onloadend = () => {
- setQrCodeDataUri(reader.result as string);
- };
- reader.readAsDataURL(blob);
- }
- } catch (error) {
- console.error('Failed to fetch QR code:', error);
- }
- };
-
- fetchQRCode();
- }, [asset?.id]);
-
- // Print handler
- const handlePrint = () => {
- if (!asset) return;
-
- // Set page title to asset name
- const originalTitle = document.title;
- document.title = asset.name;
-
- // Trigger print
- window.print();
-
- // Restore original title
- document.title = originalTitle;
- };
-
- if (isLoading) {
- return (
-
-
-
-
-
- );
- }
-
- if (!asset) {
- return (
-
-
Asset not found.
-
-
- );
- }
-
- const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
- { key: "overview", label: "Overview", icon: },
- { key: "history", label: "History", icon: },
- { key: "maintenance", label: "Maintenance", icon: },
- { key: "documents", label: "Documents", icon: },
- { key: "notes", label: "Notes", icon: },
- ];
-
- return (
-
- {/* Back */}
-
-
- {/* Header */}
-
-
-
-
-
- {asset.assetId}
-
-
-
{asset.name}
- {asset.description && (
-
{asset.description}
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Tabs */}
-
- {tabs.map(({ key, label, icon }) => (
-
- ))}
-
-
- {/* ── Overview ── */}
- {tab === "overview" && (
-
-
-
Asset Details
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Financial & Dates
-
-
-
-
-
-
-
-
-
-
- {/* QR Code - visible in print */}
- {qrCodeDataUri && (
-
-
QR Code
-
-

-
-
- )}
-
- {(asset.tags?.length || asset.notes) && (
-
- {asset.tags && asset.tags.length > 0 && (
-
-
Tags
-
- {asset.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
- )}
- {asset.notes && (
-
-
Notes
-
{asset.notes}
-
- )}
-
- )}
-
- )}
-
- {/* ── History ── */}
- {tab === "history" && (
-
-
Change History
- {historyLoading ? (
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
- ) : history.length === 0 ? (
-
No history recorded yet.
- ) : (
-
- {history.map((event) => (
- -
-
-
-
{event.description}
-
- {format(new Date(event.createdAt), "MMM d, yyyy · h:mm a")}
- {event.performedBy && ` · ${event.performedBy.name}`}
-
-
- ))}
-
- )}
-
- )}
-
- {/* ── Maintenance ── */}
- {tab === "maintenance" && (
-
-
-
Maintenance Records
-
-
- {maintenanceLoading ? (
-
- {[1, 2].map((i) => )}
-
- ) : maintenance.length === 0 ? (
-
No maintenance records.
- ) : (
-
- {maintenance.map((record) => (
-
-
-
- {record.type}
-
-
-
{record.description}
-
- Scheduled: {format(new Date(record.scheduledDate), "MMM d, yyyy")}
- {record.cost != null && ` · $${Number(record.cost).toLocaleString()}`}
-
-
- {record.status !== "COMPLETED" && record.status !== "CANCELLED" && (
-
- )}
-
- ))}
-
- )}
-
- )}
-
- {/* ── Documents ── */}
- {tab === "documents" && (
-
-
-
Documents
-
-
- {documentsLoading ? (
-
- {[1, 2].map((i) => )}
-
- ) : documents.length === 0 ? (
-
No documents uploaded.
- ) : (
-
- {documents.map((doc) => (
-
-
-
{doc.name}
-
- {doc.type} · {(doc.size / 1024).toFixed(1)} KB ·{" "}
- {format(new Date(doc.createdAt), "MMM d, yyyy")}
-
-
-
-
- ))}
-
- )}
-
- )}
-
- {/* ── Notes ── */}
- {tab === "notes" && (
-
-
-
-
-
Notes
- {notesLoading ? (
-
- {[1, 2].map((i) => )}
-
- ) : notes.length === 0 ? (
-
No notes yet.
- ) : (
-
- {notes.map((note) => (
-
-
-
- {note.content}
-
-
-
-
- {note.createdBy.name} ·{" "}
- {format(new Date(note.createdAt), "MMM d, yyyy · h:mm a")}
-
-
- ))}
-
- )}
-
-
- )}
-
- {/* ── Confirm Dialogs ── */}
- {confirmDelete && (
-
deleteAsset()}
- onCancel={() => setConfirmDelete(false)}
- />
- )}
-
- {confirmDeleteDoc && (
- {
- deleteDoc(confirmDeleteDoc);
- setConfirmDeleteDoc(null);
- }}
- onCancel={() => setConfirmDeleteDoc(null)}
- />
- )}
-
- {confirmDeleteNote && (
- {
- deleteNote(confirmDeleteNote);
- setConfirmDeleteNote(null);
- }}
- onCancel={() => setConfirmDeleteNote(null)}
- />
- )}
-
- {/* ── Modals ── */}
- {showScheduleMaintenance && (
- setShowScheduleMaintenance(false)}
- />
- )}
-
- {showUploadDoc && (
- setShowUploadDoc(false)} />
- )}
-
- );
-}
+export { default } from '@/opsce/features/reports/ReportsPage';
diff --git a/frontend/opsce/features/assets/AssetHistoryTimeline.tsx b/frontend/opsce/features/assets/AssetHistoryTimeline.tsx
new file mode 100644
index 00000000..1a3dc6ea
--- /dev/null
+++ b/frontend/opsce/features/assets/AssetHistoryTimeline.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { format } from 'date-fns';
+import {
+ PlusCircle,
+ Edit,
+ ArrowRightLeft,
+ Wrench,
+ FileText,
+ MessageSquare,
+ Upload,
+ Clock,
+} from 'lucide-react';
+import { toast } from '@/components/ui/toast';
+import { api } from '@/lib/api';
+import type { AssetHistoryEvent, AssetHistoryAction } from '@/lib/query/types/asset';
+
+const actionConfig: Record = {
+ CREATED: { icon: , color: 'bg-green-100 text-green-700' },
+ UPDATED: { icon: , color: 'bg-blue-100 text-blue-700' },
+ STATUS_CHANGED: { icon: , color: 'bg-yellow-100 text-yellow-700' },
+ TRANSFERRED: { icon: , color: 'bg-purple-100 text-purple-700' },
+ MAINTENANCE: { icon: , color: 'bg-orange-100 text-orange-700' },
+ NOTE_ADDED: { icon: , color: 'bg-gray-100 text-gray-600' },
+ DOCUMENT_UPLOADED: { icon: , color: 'bg-teal-100 text-teal-700' },
+};
+
+function ActionIcon({ action }: { action: AssetHistoryAction }) {
+ const config = actionConfig[action] ?? actionConfig.UPDATED;
+ return (
+
+ {config.icon}
+
+ );
+}
+
+function ActionBadge({ action }: { action: AssetHistoryAction }) {
+ const config = actionConfig[action] ?? actionConfig.UPDATED;
+ return (
+
+ {action.replace(/_/g, ' ')}
+
+ );
+}
+
+interface AssetHistoryTimelineProps {
+ assetId: string;
+ limit?: number;
+}
+
+export function AssetHistoryTimeline({ assetId, limit = 20 }: AssetHistoryTimelineProps) {
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+ const [page, setPage] = useState(1);
+ const scrollRef = useRef(null);
+
+ // Initial fetch
+ useEffect(() => {
+ if (!assetId) return;
+
+ const fetchEvents = async () => {
+ setLoading(true);
+ try {
+ const response = await api.get(`/assets/${assetId}/history`, {
+ params: { page: 1, limit },
+ });
+ setEvents(response.data);
+ setHasMore(response.data.length >= limit);
+ } catch (err) {
+ console.error('Failed to fetch history:', err);
+ toast.error('Failed to load asset history');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchEvents();
+ }, [assetId, limit]);
+
+ // Load more for infinite scroll
+ const loadMore = useCallback(async () => {
+ if (loadingMore || !hasMore) return;
+ setLoadingMore(true);
+ try {
+ const nextPage = page + 1;
+ const response = await api.get(`/assets/${assetId}/history`, {
+ params: { page: nextPage, limit },
+ });
+ const newEvents = response.data;
+ setEvents((prev) => [...prev, ...newEvents]);
+ setHasMore(newEvents.length >= limit);
+ setPage(nextPage);
+ } catch (err) {
+ console.error('Failed to load more history:', err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [assetId, limit, page, loadingMore, hasMore]);
+
+ // Infinite scroll handler
+ const handleScroll = useCallback(() => {
+ if (!scrollRef.current || loadingMore || !hasMore) return;
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
+ if (scrollHeight - scrollTop - clientHeight < 100) {
+ loadMore();
+ }
+ }, [loadingMore, hasMore, loadMore]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (events.length === 0) {
+ return (
+
+
Change History
+
+
+
No history recorded yet.
+
Changes to this asset will appear here.
+
+
+ );
+ }
+
+ return (
+
+
Change History
+
+
+
+ {events.map((event, index) => (
+ -
+ {/* Timeline dot */}
+
+
+
+
+
{event.description}
+
+ {/* Show value changes if available */}
+ {event.previousValue && event.newValue && (
+
+ {Object.keys(event.newValue).map((key) => {
+ const oldVal = String(event.previousValue?.[key] ?? '—');
+ const newVal = String(event.newValue?.[key] ?? '—');
+ if (oldVal === newVal) return null;
+ return (
+
+ {key}:{' '}
+ {oldVal}
+ {newVal}
+
+ );
+ })}
+
+ )}
+
+
+ {format(new Date(event.createdAt), 'MMM d, yyyy · h:mm a')}
+ {event.performedBy && ` · ${event.performedBy.name}`}
+
+
+
+ ))}
+
+
+ {loadingMore && (
+
+ )}
+
+ {!hasMore && events.length > limit && (
+
+ All history loaded
+
+ )}
+
+
+ );
+}