Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 1 addition & 214 deletions frontend/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,214 +1 @@
// frontend/app/(dashboard)/reports/page.tsx
"use client";

import Link from "next/link";
import { format } from "date-fns";
import { BarChart3, Package } from "lucide-react";
import { clsx } from "clsx";
import { useReportsSummary } from "@/lib/query/hooks/useReports";
import { AssetStatus } from "@/lib/query/types/asset";
import { StatusBadge } from "@/components/assets/status-badge";

const STATUS_COLORS: Record<AssetStatus, string> = {
[AssetStatus.ACTIVE]: "bg-green-500",
[AssetStatus.ASSIGNED]: "bg-blue-500",
[AssetStatus.MAINTENANCE]: "bg-yellow-500",
[AssetStatus.RETIRED]: "bg-gray-400",
};

export default function ReportsPage() {
const { data, isLoading } = useReportsSummary();

if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-gray-400 text-sm">
Loading report…
</div>
);
}

if (!data) return null;

const { total, byStatus, byCategory, byDepartment, recent } = data;

const statusItems = Object.entries(byStatus) as [AssetStatus, number][];
const topCategories = [...byCategory]
.sort((a, b) => b.count - a.count)
.slice(0, 8);
const topDepartments = [...byDepartment]
.sort((a, b) => b.count - a.count)
.slice(0, 8);

return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<p className="text-sm text-gray-500 mt-1">Asset inventory overview</p>
</div>

{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-5 col-span-2 lg:col-span-1">
<p className="text-sm text-gray-500">Total Assets</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{total}</p>
</div>
{statusItems.map(([status, count]) => (
<div
key={status}
className="bg-white rounded-xl border border-gray-200 p-5"
>
<p className="text-sm text-gray-500 capitalize">
{status.toLowerCase()}
</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{count}</p>
</div>
))}
</div>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* By Status bar */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4 flex items-center gap-2">
<BarChart3 size={15} className="text-gray-400" />
Assets by Status
</h2>
<div className="space-y-3">
{statusItems.map(([status, count]) => {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div key={status}>
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span className="capitalize">{status.toLowerCase()}</span>
<span>
{count} ({pct}%)
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-all",
STATUS_COLORS[status],
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>

{/* By Category */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4 flex items-center gap-2">
<BarChart3 size={15} className="text-gray-400" />
Assets by Category
</h2>
{topCategories.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">No data</p>
) : (
<div className="space-y-3">
{topCategories.map(({ name, count }) => {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div key={name}>
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{name}</span>
<span>
{count} ({pct}%)
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-gray-900 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Department */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4 flex items-center gap-2">
<BarChart3 size={15} className="text-gray-400" />
Assets by Department
</h2>
{topDepartments.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">No data</p>
) : (
<div className="space-y-3">
{topDepartments.map(({ name, count }) => {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div key={name}>
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{name}</span>
<span>
{count} ({pct}%)
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>

{/* Recent Assets */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Package size={15} className="text-gray-400" />
Recently Added
</h2>
<Link
href="/assets"
className="text-xs text-gray-400 hover:text-gray-900 hover:underline"
>
View all
</Link>
</div>
{recent.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">
No assets yet
</p>
) : (
<div className="space-y-2">
{recent.map((asset) => (
<Link
key={asset.id}
href={`/assets/${asset.id}`}
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0 hover:bg-gray-50 -mx-2 px-2 rounded-lg transition-colors"
>
<div>
<p className="text-sm font-medium text-gray-900">
{asset.name}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{asset.assetId} · {asset.category?.name ?? "—"} ·{" "}
{format(new Date(asset.createdAt), "MMM d, yyyy")}
</p>
</div>
<StatusBadge status={asset.status} />
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
export { default } from '@/opsce/features/reports/ReportsPage';
161 changes: 161 additions & 0 deletions frontend/opsce/features/assets/AssetQRCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
'use client';

import { useState, useEffect } from 'react';
import { QrCode, Download, Printer, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast';
import { api } from '@/lib/api';

interface AssetQRCodeProps {
assetId: string;
assetName: string;
size?: number;
}

export function AssetQRCode({ assetId, assetName, size = 200 }: AssetQRCodeProps) {
const [qrCodeDataUri, setQrCodeDataUri] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!assetId) return;

const fetchQR = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/assets/${assetId}/qr?format=base64`, {
responseType: 'blob',
});

const blob = response.data as Blob;
const reader = new FileReader();
reader.onloadend = () => {
setQrCodeDataUri(reader.result as string);
setLoading(false);
};
reader.onerror = () => {
setError('Failed to load QR code');
setLoading(false);
};
reader.readAsDataURL(blob);
} catch (err) {
console.error('Failed to fetch QR code:', err);
setError('Failed to load QR code');
setLoading(false);
}
};

fetchQR();
}, [assetId]);

const handleDownloadPNG = () => {
if (!qrCodeDataUri) return;
const link = document.createElement('a');
link.href = qrCodeDataUri;
link.download = `${assetName.replace(/\s+/g, '_')}_qr.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('QR code downloaded');
};

const handlePrint = () => {
if (!qrCodeDataUri) return;
const printWindow = window.open('', '_blank');
if (!printWindow) {
toast.error('Please allow pop-ups to print');
return;
}

printWindow.document.write(`
<html>
<head>
<title>QR Code - ${assetName}</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.container { text-align: center; }
h2 { margin-bottom: 8px; color: #111; font-size: 16px; }
p { margin: 4px 0; color: #666; font-size: 12px; }
img { width: 200px; height: 200px; margin: 16px 0; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>
<div class="container">
<h2>${assetName}</h2>
<p>ID: ${assetId}</p>
<img src="${qrCodeDataUri}" alt="QR Code" />
<p>Scan to view asset details</p>
</div>
<script>window.print(); window.close();</script>
</body>
</html>
`);
printWindow.document.close();
};

if (loading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 flex items-center justify-center" style={{ height: size + 80 }}>
<div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-gray-300 border-t-gray-900 rounded-full animate-spin" />
<p className="text-xs text-gray-400">Loading QR code...</p>
</div>
</div>
);
}

if (error || !qrCodeDataUri) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 flex items-center justify-center" style={{ height: size + 80 }}>
<div className="flex flex-col items-center gap-2">
<AlertCircle size={24} className="text-red-400" />
<p className="text-xs text-red-500">{error || 'QR code unavailable'}</p>
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}

return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-4">
<QrCode size={16} className="text-gray-400" />
<h2 className="text-sm font-semibold text-gray-900">QR Code</h2>
</div>

<div className="flex justify-center mb-4">
<img
src={qrCodeDataUri}
alt={`QR Code for ${assetName}`}
style={{ width: size, height: size }}
className="rounded-lg"
/>
</div>

<div className="flex gap-2">
<Button size="sm" variant="outline" className="flex-1" onClick={handleDownloadPNG}>
<Download size={14} className="mr-1.5" />
Download PNG
</Button>
<Button size="sm" variant="outline" className="flex-1" onClick={handlePrint}>
<Printer size={14} className="mr-1.5" />
Print Tag
</Button>
</div>
</div>
);
}
Loading
Loading