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
67 changes: 67 additions & 0 deletions PULL_REQUEST_SIMPLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Pull Request: Implement PendingApprovalBadge Component

## 🎯 Issue

Closes #185: Multi-party approval UI - Create PendingApprovalBadge component

## 📝 Description

This PR adds a pending approval badge to the navbar showing how many approvals need attention. Admins and shelter staff will see a red badge with the count on the "Approvals" link, and the system checks for updates every 5 minutes automatically.

## ✨ What's New

**Badge Component** - Shows a red badge with pending approval count on the Approvals nav link

**Approvals Page** - A dedicated page where admins and shelter staff can see and review all pending approvals

**Auto-Polling** - Checks the backend every 5 minutes for new approvals

**Secure Access** - Badge and Approvals link only show up for admin and shelter users

**Smart Display** - Shows "9+" for high counts, hides when count is 0

## 🧪 Tests

32 comprehensive tests ensure the badge displays correctly, role-based access works, polling happens on schedule, and error states are handled properly.

## 📁 What Changed

**Created:** 10 new files (components, hooks, services, tests, and Approvals page)
**Modified:** Navbar.tsx (integrated badge and Approvals link)

## 🔐 Security

Only admins and shelter staff can see the badge and access the Approvals page. Regular users are automatically redirected away.

## 📊 API Integration

Calls `GET /shelter/approvals?status=PENDING&limit=0` every 5 minutes to get the latest pending approvals.

## 🎨 UI Details

- Red circular badge showing count in top-right of Approvals link
- Shows "9+" for 10+ pending approvals
- Handles loading, errors, and empty states with clear messages
- Badge auto-hides when count reaches 0

## ✅ Requirements Complete

- ✅ API polling every 5 minutes
- ✅ Red badge on Approvals nav item
- ✅ "9+" display cap
- ✅ Role-based visibility (SHELTER/ADMIN only)
- ✅ Badge hides at 0 count
- ✅ Full test coverage
- ✅ ApprovalsPage implementation
- ✅ Proper error handling

## 🚀 Next Steps

Once merged, we can add approval action buttons (approve/reject) and email notifications.

---

**Type:** Feature
**Size:** XS (~2-4 hours)
**Epic:** Multi-party approval UI
**Labels:** ui, phase-2, frontend
114 changes: 114 additions & 0 deletions src/api/__tests__/approvalService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { approvalService } from "../approvalService";

// Mock the API client
vi.mock("../../lib/api-client", () => ({
apiClient: {
get: vi.fn(),
},
}));

import { apiClient } from "../../lib/api-client";

describe("approvalService", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should call apiClient.get with correct endpoint", async () => {
const mockResponse = {
data: [],
total: 0,
limit: 0,
offset: 0,
};

(apiClient.get as any).mockResolvedValue(mockResponse);

await approvalService.getPendingApprovals();

expect(apiClient.get).toHaveBeenCalledWith(
"/shelter/approvals?status=PENDING&limit=0",
);
});

it("should return approval response data", async () => {
const mockResponse = {
data: [
{
id: "1",
status: "PENDING" as const,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
},
{
id: "2",
status: "PENDING" as const,
createdAt: "2026-01-02",
updatedAt: "2026-01-02",
},
],
total: 2,
limit: 0,
offset: 0,
};

(apiClient.get as any).mockResolvedValue(mockResponse);

const result = await approvalService.getPendingApprovals();

expect(result).toEqual(mockResponse);
expect(result.total).toBe(2);
expect(result.data).toHaveLength(2);
});

it("should handle empty approval list", async () => {
const mockResponse = {
data: [],
total: 0,
limit: 0,
offset: 0,
};

(apiClient.get as any).mockResolvedValue(mockResponse);

const result = await approvalService.getPendingApprovals();

expect(result.data).toEqual([]);
expect(result.total).toBe(0);
});

it("should handle API errors", async () => {
const error = new Error("API Error");
(apiClient.get as any).mockRejectedValue(error);

await expect(approvalService.getPendingApprovals()).rejects.toThrow(
"API Error",
);
});

it("should include all required fields in response", async () => {
const mockResponse = {
data: [
{
id: "approval-123",
status: "PENDING" as const,
createdAt: "2026-01-01T10:00:00Z",
updatedAt: "2026-01-01T10:00:00Z",
},
],
total: 1,
limit: 0,
offset: 0,
};

(apiClient.get as any).mockResolvedValue(mockResponse);

const result = await approvalService.getPendingApprovals();

expect(result.data[0]).toHaveProperty("id");
expect(result.data[0]).toHaveProperty("status");
expect(result.data[0]).toHaveProperty("createdAt");
expect(result.data[0]).toHaveProperty("updatedAt");
});
});
21 changes: 21 additions & 0 deletions src/api/approvalService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { apiClient } from "../lib/api-client";

export interface ApprovalItem {
id: string;
status: "PENDING" | "APPROVED" | "REJECTED";
createdAt: string;
updatedAt: string;
}

export interface ApprovalResponse {
data: ApprovalItem[];
total: number;
limit: number;
offset: number;
}

export const approvalService = {
async getPendingApprovals(): Promise<ApprovalResponse> {
return apiClient.get("/shelter/approvals?status=PENDING&limit=0");
},
};
75 changes: 67 additions & 8 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { Link, useLocation } from "react-router-dom";
import { House, Eye, List, Heart, ChevronDown } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import {
House,
Eye,
List,
Heart,
Bell,

Check failure on line 7 in src/components/layout/Navbar.tsx

View workflow job for this annotation

GitHub Actions / validate

'Bell' is defined but never used
ChevronDown,
CheckCircle,
} from "lucide-react";
import { NotificationBell } from "../notifications/NotificationBell";

Check failure on line 11 in src/components/layout/Navbar.tsx

View workflow job for this annotation

GitHub Actions / validate

'NotificationBell' is defined but never used
import logo from "../../assets/logo.svg";
import owner from "../../assets/owner.png";
import { NotificationCentreDropdown } from "../notifications";
import { PendingApprovalBadge } from "./PendingApprovalBadge";
import { useRoleGuard } from "../../hooks/useRoleGuard";

const navLinks = [
{ label: "Home", path: "/home", icon: House },
{ label: "Interests", path: "/interests", icon: Eye },
{ label: "Listings", path: "/listings", icon: List },
];

const roleBasedNavLinks = [
{
label: "Approvals",
path: "/approvals",
icon: CheckCircle,
roles: ["admin", "shelter"],
},
];

export function Navbar() {
const location = useLocation();
const navigate = useNavigate();

Check failure on line 34 in src/components/layout/Navbar.tsx

View workflow job for this annotation

GitHub Actions / validate

'navigate' is assigned a value but never used
const { role } = useRoleGuard();

// Filter role-based links based on current user role
const visibleRoleLinks = roleBasedNavLinks.filter((link) =>
link.roles.includes(role || ""),
);

return (
<nav className="sticky top-0 z-50 w-full bg-white border-b border-gray-100 px-6 py-4 flex items-center justify-between">
Expand All @@ -36,11 +62,33 @@
key={link.path}
to={link.path}
className={`flex items-center gap-2 text-[15px] font-medium transition-colors ${
isActive ? "text-[#001323]" : "text-gray-500 hover:text-[#001323]"
isActive
? "text-[#001323]"
: "text-gray-500 hover:text-[#001323]"
}`}
>
<Icon size={20} strokeWidth={isActive ? 2.5 : 2} />
{link.label}
</Link>
);
})}

{visibleRoleLinks.map((link) => {
const Icon = link.icon;
const isActive = location.pathname === link.path;
return (
<Link
key={link.path}
to={link.path}
className={`relative flex items-center gap-2 text-[15px] font-medium transition-colors ${
isActive
? "text-[#001323]"
: "text-gray-500 hover:text-[#001323]"
}`}
>
<Icon size={20} strokeWidth={isActive ? 2.5 : 2} />
{link.label}
<PendingApprovalBadge />
</Link>
);
})}
Expand All @@ -61,13 +109,24 @@

<div className="flex items-center gap-3 ml-2 cursor-pointer group">
<div className="w-10 h-10 rounded-full overflow-hidden border-2 border-gray-100">
<img src={owner} alt="User Avatar" className="w-full h-full object-cover" />
<img
src={owner}
alt="User Avatar"
className="w-full h-full object-cover"
/>
</div>
<div className="hidden sm:block">
<p className="text-[10px] text-gray-400 font-medium">Good Morning!</p>
<p className="text-[14px] text-[#001323] font-bold">Scarlet Johnson</p>
<p className="text-[10px] text-gray-400 font-medium">
Good Morning!
</p>
<p className="text-[14px] text-[#001323] font-bold">
Scarlet Johnson
</p>
</div>
<ChevronDown size={18} className="text-gray-400 group-hover:text-gray-600 transition-colors" />
<ChevronDown
size={18}
className="text-gray-400 group-hover:text-gray-600 transition-colors"
/>
</div>
</div>
</nav>
Expand Down
24 changes: 24 additions & 0 deletions src/components/layout/PendingApprovalBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { usePendingApprovals } from "../../hooks/usePendingApprovals";
import { useRoleGuard } from "../../hooks/useRoleGuard";

export function PendingApprovalBadge() {
const { pendingCount } = usePendingApprovals();
const { role } = useRoleGuard();

// Only visible for SHELTER and ADMIN roles
const isShelterOrAdmin = role === "admin" || role === "shelter";

// Badge disappears when count reaches 0
if (pendingCount === 0 || !isShelterOrAdmin) {
return null;
}

// Max display: "9+" for counts above 9
const displayCount = pendingCount > 9 ? "9+" : String(pendingCount);

return (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-bold text-white bg-red-500 rounded-full">
{displayCount}
</span>
);
}
Loading
Loading