From 7d77f38895e2cb9258aebf2ea33b8753e6520b83 Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Mon, 29 Jun 2026 20:01:15 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Implement=20real-time=20notifications=20via?= =?UTF-8?q?=20WebSocket=20=E2=80=94=20live=20badge=20counter=20and=20notif?= =?UTF-8?q?ication=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/topbar.tsx | 170 +++++++++++++++++++------- frontend/hooks/useSocket.ts | 27 ++++ frontend/lib/socket.ts | 25 ++++ package.json | 5 + 4 files changed, 186 insertions(+), 41 deletions(-) create mode 100644 frontend/hooks/useSocket.ts create mode 100644 frontend/lib/socket.ts create mode 100644 package.json diff --git a/frontend/components/layout/topbar.tsx b/frontend/components/layout/topbar.tsx index 9aca58ee6..1154f2a07 100644 --- a/frontend/components/layout/topbar.tsx +++ b/frontend/components/layout/topbar.tsx @@ -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 = { "/dashboard": "Dashboard", @@ -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([]); + 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: ( + + ), + }); + }; + + 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 (
@@ -69,47 +117,87 @@ export function Topbar({ onMenuClick }: TopbarProps) { - {/* Right: user dropdown */} -
- + + + Notifications + + {notifications.length > 0 ? ( + notifications.map((notification) => ( + + {notification.message} + + )) ) : ( - +
+ No new notifications +
)} -
- {user && ( -
-

- {user.firstName} {user.lastName} -

-

{user.role}

+ + + +
+ + {user && ( +
+

+ {user.firstName} {user.lastName} +

+

+ {user.role} +

+
+ )} + + - {dropdownOpen && ( -
- - -
- )} + {dropdownOpen && ( +
+ + +
+ )} +
); -} +} \ No newline at end of file diff --git a/frontend/hooks/useSocket.ts b/frontend/hooks/useSocket.ts new file mode 100644 index 000000000..c0e207db1 --- /dev/null +++ b/frontend/hooks/useSocket.ts @@ -0,0 +1,27 @@ + +import { useEffect, useState } from 'react'; +import { getSocket, disconnectSocket } from '../lib/socket'; +import { Socket } from 'socket.io-client'; + +export const useSocket = () => { + const [socket, setSocket] = useState(null); + + useEffect(() => { + const newSocket = getSocket(); + setSocket(newSocket); + + return () => { + disconnectSocket(); + }; + }, []); + + const on = (event: string, callback: (...args: any[]) => void) => { + socket?.on(event, callback); + }; + + const off = (event: string, callback: (...args: any[]) => void) => { + socket?.off(event, callback); + }; + + return { socket, on, off }; +}; \ No newline at end of file diff --git a/frontend/lib/socket.ts b/frontend/lib/socket.ts new file mode 100644 index 000000000..2a5e29561 --- /dev/null +++ b/frontend/lib/socket.ts @@ -0,0 +1,25 @@ + +import { io, Socket } from 'socket.io-client'; +import { useAuthStore } from '../store/auth.store'; + +const URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +let socket: Socket; + +export const getSocket = (): Socket => { + if (!socket) { + const { accessToken } = useAuthStore.getState(); + socket = io(URL, { + auth: { + token: accessToken, + }, + }); + } + return socket; +}; + +export const disconnectSocket = () => { + if (socket) { + socket.disconnect(); + } +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 000000000..cc44b8530 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "socket.io-client": "^4.8.3" + } +} From ead2411e338d89b3f429cf40efe65bc8454aa121 Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Mon, 29 Jun 2026 20:05:55 +0100 Subject: [PATCH 2/2] Build QR code and barcode scanner --- frontend/app/(dashboard)/assets/page.tsx | 71 ++++++++++++++++++--- frontend/components/assets/ScannerModal.tsx | 69 ++++++++++++++++++++ package.json | 1 + 3 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 frontend/components/assets/ScannerModal.tsx diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx index 3209bdda2..47e9b02e4 100644 --- a/frontend/app/(dashboard)/assets/page.tsx +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -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, @@ -42,12 +66,32 @@ export default function AssetsPage() { : "No assets yet"}

- +
+ + +
+ {/* FAB for mobile */} + + {/* Filters */}
@@ -204,6 +248,13 @@ export default function AssetsPage() { onSuccess={() => refetch()} /> )} + + {showScanner && ( + setShowScanner(false)} + onScanSuccess={handleScanSuccess} + /> + )}
); -} +} \ No newline at end of file diff --git a/frontend/components/assets/ScannerModal.tsx b/frontend/components/assets/ScannerModal.tsx new file mode 100644 index 000000000..dfc757d31 --- /dev/null +++ b/frontend/components/assets/ScannerModal.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+
+

Scan QR/Barcode

+ +
+
+ {error &&

{error}

} +
+
+ ); +} \ No newline at end of file diff --git a/package.json b/package.json index cc44b8530..260a98032 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "html5-qrcode": "^2.3.8", "socket.io-client": "^4.8.3" } }