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
109 changes: 109 additions & 0 deletions frontend/app/(dashboard)/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<Notification[]>(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 (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Notifications</h1>
<p className="text-sm text-gray-500 mt-1">
{unreadCount > 0 ? `${unreadCount} unread notification${unreadCount !== 1 ? "s" : ""}` : "All caught up"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setFilter(filter === "all" ? "unread" : "all")}>
<Filter className="w-3.5 h-3.5 mr-1" />
{filter === "all" ? "Show Unread" : "Show All"}
</Button>
{unreadCount > 0 && (
<Button size="sm" onClick={markAllRead}>
<CheckCheck className="w-3.5 h-3.5 mr-1" /> Mark All Read
</Button>
)}
</div>
</div>

<div className="space-y-2">
{filtered.length === 0 ? (
<div className="bg-white border rounded-xl p-12 text-center">
<Bell className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">
{filter === "unread" ? "No unread notifications" : "No notifications yet"}
</p>
</div>
) : (
filtered.map((n) => (
<div
key={n.id}
onClick={() => { 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"
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${TYPE_ICONS[n.type]}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${n.isRead ? "font-normal text-gray-600" : "font-medium text-gray-900"}`}>{n.title}</p>
{n.message && <p className="text-xs text-gray-500 mt-0.5">{n.message}</p>}
<p className="text-xs text-gray-400 mt-1">{new Date(n.createdAt).toLocaleString()}</p>
</div>
{!n.isRead && (
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-2" />
)}
</div>
))
)}
</div>
</div>
);
}
200 changes: 141 additions & 59 deletions frontend/app/(dashboard)/transfers/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<Tab>("pending");
const [transfers, setTransfers] = useState<Transfer[]>(MOCK_TRANSFERS);
const [rejectTarget, setRejectTarget] = useState<Transfer | null>(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 (
<div style={{ padding: '2rem' }}>
<h1>Asset Transfers Inbox</h1>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
{['ALL', 'PENDING', 'APPROVED', 'REJECTED'].map((status) => (
<button
key={status}
onClick={() => setFilter(status)}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.25rem',
border: '1px solid #ccc',
backgroundColor: filter === status ? '#0070f3' : '#fff',
color: filter === status ? '#fff' : '#000',
cursor: 'pointer',
}}
>
{status}
</button>
))}
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Transfers</h1>
<p className="text-sm text-gray-500 mt-1">Manage asset transfer requests</p>
</div>

{/* Tabs */}
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-lg w-fit">
<button onClick={() => setTab("pending")} className={`px-4 py-2 rounded-md text-sm font-medium flex items-center gap-1.5 ${
tab === "pending" ? "bg-white shadow text-gray-900" : "text-gray-500 hover:text-gray-700"
}`}>
<Clock className="w-3.5 h-3.5" /> Pending
{pending.length > 0 && <span className="ml-1 bg-yellow-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">{pending.length}</span>}
</button>
<button onClick={() => setTab("my-requests")} className={`px-4 py-2 rounded-md text-sm font-medium ${
tab === "my-requests" ? "bg-white shadow text-gray-900" : "text-gray-500 hover:text-gray-700"
}`}>
My Requests
</button>
<button onClick={() => setTab("history")} className={`px-4 py-2 rounded-md text-sm font-medium ${
tab === "history" ? "bg-white shadow text-gray-900" : "text-gray-500 hover:text-gray-700"
}`}>
History
</button>
</div>

<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ padding: '0.5rem' }}>ID</th>
<th style={{ padding: '0.5rem' }}>Asset</th>
<th style={{ padding: '0.5rem' }}>From → To</th>
<th style={{ padding: '0.5rem' }}>Requester</th>
<th style={{ padding: '0.5rem' }}>Date</th>
<th style={{ padding: '0.5rem' }}>Status</th>
<th style={{ padding: '0.5rem' }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredTransfers.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.5rem' }}>{item.id}</td>
<td style={{ padding: '0.5rem' }}>{item.assetName}</td>
<td style={{ padding: '0.5rem' }}>{item.from} → {item.to}</td>
<td style={{ padding: '0.5rem' }}>{item.requester}</td>
<td style={{ padding: '0.5rem' }}>{item.date}</td>
<td style={{ padding: '0.5rem' }}><strong>{item.status}</strong></td>
<td style={{ padding: '0.5rem' }}>
{item.status === 'PENDING' && (
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button style={{ backgroundColor: '#22c55e', color: '#fff', border: 'none', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', cursor: 'pointer' }}>Approve</button>
<button style={{ backgroundColor: '#ef4444', color: '#fff', border: 'none', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', cursor: 'pointer' }}>Reject</button>
{/* Transfers list */}
<div className="space-y-3">
{currentList.length === 0 ? (
<div className="bg-white border rounded-xl p-12 text-center">
<ArrowRightLeft className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">No transfers to show</p>
</div>
) : (
currentList.map((t) => (
<div key={t.id} className="bg-white border rounded-xl p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-gray-900">{t.assetName}</p>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[t.status]}`}>
{t.status}
</span>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-sm text-gray-500">
{t.from} → {t.to} · Requested by {t.requester} · {new Date(t.createdAt).toLocaleDateString()}
</p>
{t.reason && <p className="text-xs text-gray-400 mt-1">Reason: {t.reason}</p>}
{t.rejectionReason && <p className="text-xs text-red-500 mt-1">Rejection reason: {t.rejectionReason}</p>}
</div>
<div className="flex gap-2">
{tab === "pending" && (
<>
<Button size="sm" onClick={() => handleApprove(t.id)}>
<Check className="w-3 h-3 mr-1" /> Approve
</Button>
<Button variant="outline" size="sm" onClick={() => setRejectTarget(t)}>
<X className="w-3 h-3 mr-1" /> Reject
</Button>
</>
)}
{tab === "my-requests" && t.status === "PENDING" && (
<Button variant="outline" size="sm" onClick={() => handleCancel(t.id)}>Cancel</Button>
)}
</div>
</div>
</div>
))
)}
</div>

{/* Reject dialog */}
<ConfirmDialog
open={!!rejectTarget}
onConfirm={handleReject}
onCancel={() => { setRejectTarget(null); setRejectReason(""); }}
title="Reject Transfer"
description={
<div>
<p className="mb-2">Provide a reason for rejecting this transfer:</p>
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)}
className="w-full border rounded-lg p-2 text-sm" rows={3} placeholder="Rejection reason..." />
</div>
}
/>
</div>
);
}
18 changes: 16 additions & 2 deletions frontend/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import {
KeyRound,
Wrench,
ClipboardCheck,
Store,
Bell,
FileText,
Store,
} from "lucide-react";
import { useAuthStore } from "@/store/auth.store";

Expand Down Expand Up @@ -211,8 +212,21 @@ export function Sidebar({ open, onClose }: SidebarProps) {
})}
</nav>

{/* Bottom: Settings + Logout */}
{/* Bottom: Notifications + Settings + Logout */}
<div className="px-3 py-4 border-t border-gray-100 space-y-0.5">
<Link
href="/notifications"
aria-current={pathname === "/notifications" ? "page" : undefined}
className={clsx(
"flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-sm font-medium transition-colors",
pathname === "/notifications"
? "bg-gray-100 text-gray-900"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50",
)}
>
<Bell size={17} aria-hidden="true" />
Notifications
</Link>
<Link
href="/settings"
aria-current={pathname.startsWith("/settings") ? "page" : undefined}
Expand Down
Loading