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
18 changes: 18 additions & 0 deletions PR_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# feat: Implement ApprovalDecisionButtons Component

## Summary

Approve/reject buttons for adoption approvals with rejection reason modal.

## Changes

- `ApprovalDecisionButtons` component with conditional rendering
- Approve button (one-click submission)
- Reject button (opens modal for reason)
- Toast notifications on success/error
- `submitApprovalDecision` method in adoptionApprovalsService
- 18 unit tests

## Tests

All passing - covers visibility, mutations, modals, and accessibility
22 changes: 22 additions & 0 deletions PULL_REQUEST_ADOPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Pull Request: Add useAdoptionApprovals Hook

## Description
Hook to fetch and monitor adoption approval state. Automatically checks for approvals every 30 seconds and stops polling once quorum is reached.

## What's New
- `useAdoptionApprovals(adoptionId)` hook fetching approval state
- Polls `GET /adoption/:id/approvals` every 30s while `quorumMet` is false
- Returns: `{required, given, pending, quorumMet, escrowAccountId, isLoading, isError}`
- Stops polling automatically when quorum is met

## Files Added
- `src/api/adoptionApprovalsService.ts` - Service layer
- `src/hooks/useAdoptionApprovals.ts` - Main hook
- Test files with 18 total tests

## Test Coverage
- Pre-quorum and post-quorum states
- Polling behavior verification
- Polling stops when quorumMet:true ✓

Closes adoption approvals issue
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
173 changes: 173 additions & 0 deletions src/api/__tests__/adoptionApprovalsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { adoptionApprovalsService } from "../adoptionApprovalsService";

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

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

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

it("should call apiClient.get with correct endpoint", async () => {
const mockResponse = {
required: 3,
given: 1,
pending: 2,
quorumMet: false,
escrowAccountId: "escrow-123",
parties: [],
};

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

await adoptionApprovalsService.getAdoptionApprovals("adoption-123");

expect(apiClient.get).toHaveBeenCalledWith(
"/adoption/adoption-123/approvals",
);
});

it("should return approval state data correctly", async () => {
const mockResponse = {
required: 3,
given: 1,
pending: 2,
quorumMet: false,
escrowAccountId: "escrow-456",
parties: [
{
id: "party-1",
name: "Dr. Sarah Lee",
role: "Veterinary Inspector",
status: "APPROVED",
timestamp: "2026-03-29T10:00:00Z",
},
],
};

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

const result = await adoptionApprovalsService.getAdoptionApprovals(
"adoption-123",
);

expect(result).toEqual(mockResponse);
expect(result.required).toBe(3);
expect(result.given).toBe(1);
expect(result.pending).toBe(2);
expect(result.quorumMet).toBe(false);
});

it("should handle quorum met state", async () => {
const mockResponse = {
required: 3,
given: 3,
pending: 0,
quorumMet: true,
escrowAccountId: "escrow-789",
parties: [
{
id: "party-1",
name: "Dr. Sarah Lee",
role: "Veterinary Inspector",
status: "APPROVED",
timestamp: "2026-03-29T10:00:00Z",
},
{
id: "party-2",
name: "Mark Evans",
role: "Welfare Officer",
status: "APPROVED",
timestamp: "2026-03-29T11:30:00Z",
},
{
id: "party-3",
name: "Jane Smith",
role: "Legal Reviewer",
status: "APPROVED",
timestamp: "2026-03-29T12:15:00Z",
},
],
};

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

const result = await adoptionApprovalsService.getAdoptionApprovals(
"adoption-123",
);

expect(result.quorumMet).toBe(true);
expect(result.given).toBe(result.required);
expect(result.pending).toBe(0);
});

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

await expect(
adoptionApprovalsService.getAdoptionApprovals("adoption-123"),
).rejects.toThrow("API Error");
});

it("should include parties in response", async () => {
const mockResponse = {
required: 2,
given: 1,
pending: 1,
quorumMet: false,
escrowAccountId: "escrow-123",
parties: [
{
id: "party-1",
name: "Alice Smith",
role: "Inspector",
status: "APPROVED",
timestamp: "2026-03-29T10:00:00Z",
},
{
id: "party-2",
name: "Bob Johnson",
role: "Officer",
status: "PENDING",
},
],
};

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

const result = await adoptionApprovalsService.getAdoptionApprovals(
"adoption-123",
);

expect(result.parties).toHaveLength(2);
expect(result.parties[0].status).toBe("APPROVED");
expect(result.parties[1].status).toBe("PENDING");
});

it("should handle different adoption IDs", async () => {
const mockResponse = {
required: 3,
given: 2,
pending: 1,
quorumMet: false,
escrowAccountId: "escrow-999",
parties: [],
};

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

await adoptionApprovalsService.getAdoptionApprovals("adoption-different");

expect(apiClient.get).toHaveBeenCalledWith(
"/adoption/adoption-different/approvals",
);
});
});
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");
});
});
Loading
Loading