diff --git a/apps/web/src/components/ui/ProposalCard.test.tsx b/apps/web/src/components/ui/ProposalCard.test.tsx new file mode 100644 index 0000000..5c2faff --- /dev/null +++ b/apps/web/src/components/ui/ProposalCard.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { ProposalCard } from "../ProposalCard"; + +describe("ProposalCard Action Requirements", () => { + it("renders Execute button only if user is a member and proposal is approved", () => { + const { rerender } = render( + {}} + onFinalize={() => {}} + /> + ); + expect(screen.getByText("Execute Withdrawal")).toBeInTheDocument(); + + // Re-render with membership set to false + rerender( + {}} + onFinalize={() => {}} + /> + ); + expect(screen.queryByText("Execute Withdrawal")).toBeNull(); + }); + + it("shows Finalize button instead of choice operations on expired entries", () => { + render( + {}} + onFinalize={() => {}} + /> + ); + expect(screen.getByText("Finalize")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/apps/web/src/components/ui/ProposalCard.tsx b/apps/web/src/components/ui/ProposalCard.tsx new file mode 100644 index 0000000..2466a2b --- /dev/null +++ b/apps/web/src/components/ui/ProposalCard.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect } from "react"; + +export type ProposalStatus = "pending" | "approved" | "executed" | "rejected" | "expired"; + +interface ProposalCardProps { + proposal: { + id: string; + status: ProposalStatus; + expiryLedger: number; // Block index target for expiration + }; + currentLedger: number; // Passed from global ledger context state sync + isMember: boolean; + onExecute: (id: string) => void; + onFinalize: (id: string) => void; +} + +export const ProposalCard: React.FC = ({ + proposal, + currentLedger, + isMember, + onExecute, + onFinalize, +}) => { + const [isCollapsed, setIsCollapsed] = useState( + proposal.status === "executed" || proposal.status === "rejected" + ); + const [timeLeft, setTimeLeft] = useState(""); + + // Calculate countdown: 1 ledger ≈ 5s. Updates every minute. + useEffect(() => { + const calculateTime = () => { + if (proposal.status !== "pending") return; + const ledgersLeft = proposal.expiryLedger - currentLedger; + if (ledgersLeft <= 0) { + setTimeLeft("Expired"); + return; + } + const totalSeconds = ledgersLeft * 5; + const minutes = Math.floor(totalSeconds / 60); + setTimeLeft(`${minutes}m left`); + }; + + calculateTime(); + const interval = setInterval(calculateTime, 60000); // 1-minute updates + return () => clearInterval(interval); + }, [currentLedger, proposal.expiryLedger, proposal.status]); + + // Map explicitly defined color configurations + const badgeColors: Record = { + pending: "bg-yellow-500 text-black", + approved: "bg-blue-500 text-white", + executed: "bg-green-500 text-white", + rejected: "bg-red-500 text-white", + expired: "bg-gray-500 text-white", + }; + + return ( +
+
+

Proposal #{proposal.id}

+ + {proposal.status.toUpperCase()} + +
+ + {/* Expiry Countdown for pending proposals */} + {proposal.status === "pending" &&

{timeLeft}

} + + {/* Collapsible content section toggle wrapper */} + {(proposal.status === "executed" || proposal.status === "rejected") && ( + + )} + + {!isCollapsed && ( +
+ {/* Execute button only visible to verified members when status is approved */} + {proposal.status === "approved" && isMember && ( + + )} + + {/* Expired proposals replace approve/reject with a Finalize button */} + {proposal.status === "expired" && ( + + )} +
+ )} +
+ ); +}; \ No newline at end of file