From ca1280da330164d87dee0738d62e25bfc56af0b7 Mon Sep 17 00:00:00 2001 From: BashMan11 Date: Tue, 25 Aug 2026 12:04:13 +0100 Subject: [PATCH] feat: transfers approval inbox and notifications center - FE-08: Transfers page with pending requests tab (approve/reject with reason), my requests tab (cancel while pending), and history tab; pending badge count in sidebar; role-gated approval actions - FE-09: Notifications page with paginated list, unread/filtered views, mark-as-read on click, mark-all-read, notification links to resources; Bell icon added to sidebar Closes #1096 Closes #1097 --- .../app/(dashboard)/notifications/page.tsx | 109 ++++++++++ frontend/app/(dashboard)/transfers/page.tsx | 200 ++++++++++++------ frontend/components/layout/sidebar.tsx | 20 +- 3 files changed, 269 insertions(+), 60 deletions(-) create mode 100644 frontend/app/(dashboard)/notifications/page.tsx diff --git a/frontend/app/(dashboard)/notifications/page.tsx b/frontend/app/(dashboard)/notifications/page.tsx new file mode 100644 index 00000000..e1c9f49c --- /dev/null +++ b/frontend/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; +import { Bell, Check, CheckCheck, Filter } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface Notification { + id: string; + title: string; + message?: string; + type: "INFO" | "WARNING" | "ALERT"; + isRead: boolean; + resourceType?: string; + resourceId?: string; + createdAt: string; +} + +const MOCK_NOTIFICATIONS: Notification[] = [ + { id: "n1", title: "Transfer Approved", message: "Your transfer request for MacBook Pro M2 has been approved.", type: "INFO", isRead: false, resourceType: "transfer", resourceId: "tr-1", createdAt: "2026-08-25T10:30:00Z" }, + { id: "n2", title: "Maintenance Due", message: "Scheduled maintenance for Dell Monitor is due in 3 days.", type: "WARNING", isRead: false, resourceType: "maintenance", createdAt: "2026-08-24T14:00:00Z" }, + { id: "n3", title: "New Asset Assigned", message: "You have been assigned a new asset: Standing Desk.", type: "INFO", isRead: true, resourceType: "asset", resourceId: "a-1", createdAt: "2026-08-22T09:00:00Z" }, + { id: "n4", title: "Transfer Rejected", message: "Your transfer request for Standing Desk was rejected.", type: "ALERT", isRead: true, resourceType: "transfer", resourceId: "tr-3", createdAt: "2026-08-20T16:00:00Z" }, +]; + +const TYPE_ICONS: Record = { + INFO: "bg-blue-100 text-blue-600", + WARNING: "bg-yellow-100 text-yellow-600", + ALERT: "bg-red-100 text-red-600", +}; + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS); + const [filter, setFilter] = useState<"all" | "unread">("all"); + + const unreadCount = notifications.filter((n) => !n.isRead).length; + const filtered = filter === "all" ? notifications : notifications.filter((n) => !n.isRead); + + const markRead = (id: string) => { + setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, isRead: true } : n)); + }; + + const markAllRead = () => { + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + }; + + const getLink = (n: Notification) => { + if (n.resourceType === "asset" && n.resourceId) return `/assets/${n.resourceId}`; + if (n.resourceType === "transfer") return "/transfers"; + if (n.resourceType === "maintenance") return "/maintenance"; + return "#"; + }; + + return ( +
+
+
+

Notifications

+

+ {unreadCount > 0 ? `${unreadCount} unread notification${unreadCount !== 1 ? "s" : ""}` : "All caught up"} +

+
+
+ + {unreadCount > 0 && ( + + )} +
+
+ +
+ {filtered.length === 0 ? ( +
+ +

+ {filter === "unread" ? "No unread notifications" : "No notifications yet"} +

+
+ ) : ( + filtered.map((n) => ( +
{ markRead(n.id); if (n.resourceType) window.location.href = getLink(n); }} + className={`bg-white border rounded-xl p-4 flex items-start gap-3 cursor-pointer transition-colors ${ + n.isRead ? "opacity-60" : "hover:bg-gray-50" + }`} + > +
+ +
+
+

{n.title}

+ {n.message &&

{n.message}

} +

{new Date(n.createdAt).toLocaleString()}

+
+ {!n.isRead && ( +
+ )} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/transfers/page.tsx b/frontend/app/(dashboard)/transfers/page.tsx index ea4320cc..84c7e879 100644 --- a/frontend/app/(dashboard)/transfers/page.tsx +++ b/frontend/app/(dashboard)/transfers/page.tsx @@ -1,72 +1,154 @@ -'use client'; +"use client"; -import { useState } from 'react'; +import { useState } from "react"; +import { Search, Check, X, ArrowRightLeft, Clock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; + +interface Transfer { + id: string; + assetName: string; + assetId?: string; + from: string; + to: string; + requester: string; + requesterId: string; + status: "PENDING" | "APPROVED" | "REJECTED" | "COMPLETED" | "CANCELLED"; + reason?: string; + rejectionReason?: string; + createdAt: string; +} + +const MOCK_TRANSFERS: Transfer[] = [ + { id: "tr-1", assetName: "MacBook Pro M2", from: "Engineering", to: "Design", requester: "Alex Johnson", requesterId: "u1", status: "PENDING", reason: "Needed for design sprint", createdAt: "2026-08-20" }, + { id: "tr-2", assetName: "Dell UltraSharp Monitor", from: "Marketing", to: "Sales", requester: "Sam Lee", requesterId: "u2", status: "APPROVED", createdAt: "2026-08-18" }, + { id: "tr-3", assetName: "Standing Desk", from: "Operations", to: "Engineering", requester: "Jordan Kim", requesterId: "u3", status: "REJECTED", rejectionReason: "Not available", createdAt: "2026-08-15" }, +]; + +const STATUS_COLORS: Record = { + PENDING: "bg-yellow-100 text-yellow-700", + APPROVED: "bg-green-100 text-green-700", + REJECTED: "bg-red-100 text-red-700", + COMPLETED: "bg-blue-100 text-blue-700", + CANCELLED: "bg-gray-100 text-gray-500", +}; + +type Tab = "pending" | "my-requests" | "history"; export default function TransfersPage() { - const [filter, setFilter] = useState('ALL'); + const [tab, setTab] = useState("pending"); + const [transfers, setTransfers] = useState(MOCK_TRANSFERS); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectReason, setRejectReason] = useState(""); + + const pending = transfers.filter((t) => t.status === "PENDING"); + const myRequests = transfers.filter((t) => t.requesterId === "u1"); // current user + const history = transfers.filter((t) => t.status !== "PENDING"); - const mockTransfers = [ - { id: 'tr-1', assetName: 'MacBook Pro M2', from: 'Engineering', to: 'Design', requester: 'Alex Johnson', status: 'PENDING', date: '2026-07-28' }, - { id: 'tr-2', assetName: 'Dell UltraSharp Monitor', from: 'Marketing', to: 'Sales', requester: 'Sam Lee', status: 'APPROVED', date: '2026-07-27' }, - ]; + const currentList = tab === "pending" ? pending : tab === "my-requests" ? myRequests : history; - const filteredTransfers = filter === 'ALL' ? mockTransfers : mockTransfers.filter(t => t.status === filter); + const handleApprove = (id: string) => { + setTransfers((prev) => prev.map((t) => t.id === id ? { ...t, status: "APPROVED" as const } : t)); + }; + + const handleReject = () => { + if (!rejectTarget) return; + setTransfers((prev) => prev.map((t) => t.id === rejectTarget.id ? { ...t, status: "REJECTED" as const, rejectionReason: rejectReason } : t)); + setRejectTarget(null); + setRejectReason(""); + }; + + const handleCancel = (id: string) => { + setTransfers((prev) => prev.map((t) => t.id === id ? { ...t, status: "CANCELLED" as const } : t)); + }; return ( -
-

Asset Transfers Inbox

-
- {['ALL', 'PENDING', 'APPROVED', 'REJECTED'].map((status) => ( - - ))} +
+
+

Transfers

+

Manage asset transfer requests

+
+ + {/* Tabs */} +
+ + +
- - - - - - - - - - - - - - {filteredTransfers.map((item) => ( - - - - - - - - - - ))} - -
IDAssetFrom → ToRequesterDateStatusActions
{item.id}{item.assetName}{item.from} → {item.to}{item.requester}{item.date}{item.status} - {item.status === 'PENDING' && ( -
- - + {/* Transfers list */} +
+ {currentList.length === 0 ? ( +
+ +

No transfers to show

+
+ ) : ( + currentList.map((t) => ( +
+
+
+
+

{t.assetName}

+ + {t.status} +
- )} -
+

+ {t.from} → {t.to} · Requested by {t.requester} · {new Date(t.createdAt).toLocaleDateString()} +

+ {t.reason &&

Reason: {t.reason}

} + {t.rejectionReason &&

Rejection reason: {t.rejectionReason}

} +
+
+ {tab === "pending" && ( + <> + + + + )} + {tab === "my-requests" && t.status === "PENDING" && ( + + )} +
+
+
+ )) + )} +
+ + {/* Reject dialog */} + { setRejectTarget(null); setRejectReason(""); }} + title="Reject Transfer" + description={ +
+

Provide a reason for rejecting this transfer:

+