Skip to content
Merged
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
71 changes: 61 additions & 10 deletions frontend/app/(dashboard)/assets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,46 @@

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Search, SlidersHorizontal } from "lucide-react";
import { Plus, Search, SlidersHorizontal, ScanLine } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/assets/status-badge";
import { ConditionBadge } from "@/components/assets/condition-badge";
import { CreateAssetModal } from "@/components/assets/create-asset-modal";
import { ScannerModal } from "@/components/assets/ScannerModal";
import { useAssets } from "@/lib/query/hooks/useAssets";
import { AssetStatus, AssetCondition } from "@/lib/query/types/asset";
import { useToast } from "@/components/ui/use-toast";
import { apiClient } from "@/lib/api/client";

const STATUS_OPTIONS = ["All", ...Object.values(AssetStatus)];

export default function AssetsPage() {
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [showScanner, setShowScanner] = useState(false);
const { toast } = useToast();

const handleScanSuccess = async (decodedText: string) => {
setShowScanner(false);
try {
const res = await apiClient.get(`/assets/scan?code=${decodedText}`);
if (res.data) {
router.push(`/assets/${res.data.id}`);
} else {
toast({
title: "Not Found",
description: "No asset found for this code.",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "Error",
description: "Failed to fetch asset information.",
variant: "destructive",
});
}
};


const { data, isLoading, refetch } = useAssets({
search: search || undefined,
Expand All @@ -42,12 +66,32 @@ export default function AssetsPage() {
: "No assets yet"}
</p>
</div>
<Button onClick={() => setShowModal(true)}>
<Plus size={16} className="mr-1.5" />
Register Asset
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="hidden sm:flex"
onClick={() => setShowScanner(true)}
>
<ScanLine size={16} className="mr-1.5" />
Scan Asset
</Button>
<Button onClick={() => setShowModal(true)}>
<Plus size={16} className="mr-1.5" />
Register Asset
</Button>
</div>
</div>

{/* FAB for mobile */}
<Button
variant="default"
size="icon"
className="fixed bottom-4 right-4 sm:hidden rounded-full h-14 w-14 shadow-lg"
onClick={() => setShowScanner(true)}
>
<ScanLine size={24} />
</Button>

{/* Filters */}
<div className="flex gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
Expand Down Expand Up @@ -204,6 +248,13 @@ export default function AssetsPage() {
onSuccess={() => refetch()}
/>
)}

{showScanner && (
<ScannerModal
onClose={() => setShowScanner(false)}
onScanSuccess={handleScanSuccess}
/>
)}
</div>
);
}
}
69 changes: 69 additions & 0 deletions frontend/components/assets/ScannerModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@

"use client";

import { useEffect, useState } from "react";
import { Html5Qrcode } from "html5-qrcode";
import { X } from "lucide-react";

interface ScannerModalProps {
onClose: () => void;
onScanSuccess: (decodedText: string) => void;
}

export function ScannerModal({ onClose, onScanSuccess }: ScannerModalProps) {
const [cameraId, setCameraId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const scanner = new Html5Qrcode("reader");

const startScanner = async () => {
try {
const cameras = await Html5Qrcode.getCameras();
if (cameras && cameras.length) {
const selectedCameraId = cameras[0].id;
setCameraId(selectedCameraId);

await scanner.start(
selectedCameraId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
(decodedText, decodedResult) => {
onScanSuccess(decodedText);
},
(errorMessage) => {}
);
} else {
setError("No cameras found.");
}
} catch (err) {
setError("Failed to start scanner. Please grant camera permissions.");
}
};

startScanner();

return () => {
if (scanner.isScanning) {
scanner.stop().catch((err) => console.error("Failed to stop scanner", err));
}
};
}, [onScanSuccess]);

return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-4 rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Scan QR/Barcode</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-900">
<X size={24} />
</button>
</div>
<div id="reader" className="w-full h-64 bg-gray-200"></div>
{error && <p className="text-red-500 mt-2">{error}</p>}
</div>
</div>
);
}
170 changes: 129 additions & 41 deletions frontend/components/layout/topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@

import { useRouter, usePathname } from "next/navigation";
import { useState, useRef, useEffect } from "react";
import { Menu, User, ChevronDown } from "lucide-react";
import { Menu, User, ChevronDown, Bell } from "lucide-react";
import { useAuthStore } from "@/store/auth.store";
import { useSocket } from "@/hooks/useSocket";
import { useToast } from "@/components/ui/use-toast";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";

const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard",
Expand Down Expand Up @@ -49,9 +60,46 @@ export function Topbar({ onMenuClick }: TopbarProps) {
return () => document.removeEventListener("mousedown", handleClick);
}, []);

const initials = user
? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
: "?";
const { toast } = useToast();
const { on, off } = useSocket();
const [notifications, setNotifications] = useState<any[]>([]);
const [unreadCount, setUnreadCount] = useState(0);

useEffect(() => {
const handleNewNotification = (notification: any) => {
setNotifications((prev) => [notification, ...prev]);
setUnreadCount((prev) => prev + 1);
};

const handleMaintenanceDue = (data: any) => {
toast({
title: "Maintenance Due",
description: `Asset ${data.assetName} is due for maintenance.`,
action: (
<Button onClick={() => router.push("/maintenance-calendar")}>
View
</Button>
),
});
};

const handleAssetStatusChanged = (data: any) => {
if (pathname.includes(`/assets/${data.assetId}`)) {
router.refresh();
}
};

on("notification.new", handleNewNotification);
on("maintenance.due", handleMaintenanceDue);
on("asset.status_changed", handleAssetStatusChanged);

return () => {
off("notification.new", handleNewNotification);
off("maintenance.due", handleMaintenanceDue);
off("asset.status_changed", handleAssetStatusChanged);
};
}, [on, off, toast, router, pathname]);


return (
<header className="fixed top-0 left-0 lg:left-60 right-0 h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 lg:px-6 z-20">
Expand All @@ -69,47 +117,87 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</h1>
</div>

{/* Right: user dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((v) => !v)}
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
>
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
{user ? (
<span className="text-xs font-semibold text-gray-700">{initials}</span>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative rounded-full"
onClick={() => setUnreadCount(0)}
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.length > 0 ? (
notifications.map((notification) => (
<DropdownMenuItem key={notification.id}>
{notification.message}
</DropdownMenuItem>
))
) : (
<User size={15} className="text-gray-500" />
<div className="px-2 py-4 text-center text-sm text-gray-500">
No new notifications
</div>
)}
</div>
{user && (
<div className="hidden sm:block text-left">
<p className="text-sm font-medium text-gray-900 leading-none">
{user.firstName} {user.lastName}
</p>
<p className="text-xs text-gray-400 mt-0.5 capitalize">{user.role}</p>
</DropdownMenuContent>
</DropdownMenu>

<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((v) => !v)}
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
>
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
{user ? (
<span className="text-xs font-semibold text-gray-700">
{user.firstName[0]}
{user.lastName[0]}
</span>
) : (
<User size={15} className="text-gray-500" />
)}
</div>
)}
<ChevronDown size={14} className="text-gray-400 hidden sm:block" />
</button>
{user && (
<div className="hidden sm:block text-left">
<p className="text-sm font-medium text-gray-900 leading-none">
{user.firstName} {user.lastName}
</p>
<p className="text-xs text-gray-400 mt-0.5 capitalize">
{user.role}
</p>
</div>
)}
<ChevronDown size={14} className="text-gray-400 hidden sm:block" />
</button>

{dropdownOpen && (
<div className="absolute right-0 mt-2 w-44 bg-white border border-gray-200 rounded-lg shadow-md py-1 z-50">
<button
onClick={() => { setDropdownOpen(false); router.push("/settings"); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
View Profile
</button>
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-50"
>
Logout
</button>
</div>
)}
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-44 bg-white border border-gray-200 rounded-lg shadow-md py-1 z-50">
<button
onClick={() => {
setDropdownOpen(false);
router.push("/settings");
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
View Profile
</button>
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-50"
>
Logout
</button>
</div>
)}
</div>
</div>
</header>
);
}
}
Loading
Loading