diff --git a/frontend/src/components/DisputeVoting/DisputeVotingPanel.css b/frontend/src/components/DisputeVoting/DisputeVotingPanel.css
index 96b0ecd..7dfd0db 100644
--- a/frontend/src/components/DisputeVoting/DisputeVotingPanel.css
+++ b/frontend/src/components/DisputeVoting/DisputeVotingPanel.css
@@ -117,6 +117,29 @@
gap: 10px;
}
+/* Finalized state — replaces vote buttons when an outcome is set */
+.dispute-card--finalized {
+ opacity: 0.85;
+}
+
+.dispute-card__finalized-notice {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ border-radius: var(--radius);
+ background: var(--color-muted-bg, #f3f4f6);
+ color: var(--color-text-muted, #6b7280);
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.dispute-card__finalized-icon {
+ font-size: 1rem;
+ color: var(--color-success, #16a34a);
+ font-weight: 700;
+}
+
.dispute-card__voted {
font-size: 0.85rem;
color: var(--color-text-muted);
diff --git a/frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx b/frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx
new file mode 100644
index 0000000..1d3dfe3
--- /dev/null
+++ b/frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import '@testing-library/jest-dom'
+import { render, screen } from '@testing-library/react'
+import DisputeVotingPanel from './DisputeVotingPanel'
+import * as useDisputesModule from '../../hooks/useDisputes'
+import type { Dispute } from '../../types/dispute'
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const BASE_DISPUTE: Dispute = {
+ settlement_id: 42,
+ claimant_weight: 3,
+ counterparty_weight: 2,
+ resolution_weight: 5,
+ threshold: 5,
+ outcome: null,
+ votes: [],
+}
+
+function mockUseDisputes(disputes: Dispute[]) {
+ vi.spyOn(useDisputesModule, 'useDisputes').mockReturnValue({
+ disputes,
+ loading: false,
+ error: null,
+ voteDispute: vi.fn(),
+ refresh: vi.fn(),
+ lastUpdated: new Date('2025-01-01T12:00:00Z'),
+ weightChanged: false,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('DisputeVotingPanel — finalized/disabled state (issue #230)', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('renders vote buttons when a dispute is open (outcome is null)', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: null }])
+ render( )
+
+ expect(screen.getByRole('button', { name: /Vote in favor of claimant/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Vote in favor of counterparty/i })).toBeInTheDocument()
+ })
+
+ it('does NOT render vote buttons when a dispute is finalized', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }])
+ render( )
+
+ expect(screen.queryByRole('button', { name: /Vote Claimant/i })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Vote Counterparty/i })).not.toBeInTheDocument()
+ })
+
+ it('shows "Outcome finalized" notice when dispute is resolved', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }])
+ render( )
+
+ expect(screen.getByText(/Outcome finalized/i)).toBeInTheDocument()
+ })
+
+ it('shows the outcome badge with the winner label when finalized (claimant)', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }])
+ render( )
+
+ expect(screen.getByText(/Resolved: Claimant/i)).toBeInTheDocument()
+ })
+
+ it('shows the outcome badge with the winner label when finalized (counterparty)', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedCounterparty' }])
+ render( )
+
+ expect(screen.getByText(/Resolved: Counterparty/i)).toBeInTheDocument()
+ })
+
+ it('finalized notice has role=status for screen reader accessibility', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedCounterparty' }])
+ render( )
+
+ expect(screen.getByRole('status', { name: '' })).toBeInTheDocument()
+ expect(screen.getByRole('status').textContent).toMatch(/Outcome finalized/i)
+ })
+
+ it('dispute card gets dispute-card--finalized class when outcome is set', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }])
+ const { container } = render( )
+
+ const finalizedCard = container.querySelector('.dispute-card--finalized')
+ expect(finalizedCard).toBeInTheDocument()
+ })
+
+ it('open disputes do NOT get the dispute-card--finalized class', () => {
+ mockUseDisputes([{ ...BASE_DISPUTE, outcome: null }])
+ const { container } = render( )
+
+ expect(container.querySelector('.dispute-card--finalized')).not.toBeInTheDocument()
+ })
+
+ it('a mix of open and finalized disputes renders correctly', () => {
+ mockUseDisputes([
+ { ...BASE_DISPUTE, settlement_id: 1, outcome: null },
+ { ...BASE_DISPUTE, settlement_id: 2, outcome: 'ResolvedCounterparty' },
+ ])
+ render( )
+
+ // Open dispute has vote buttons (aria-label references the settlement id)
+ expect(screen.getAllByRole('button', { name: /Vote in favor of claimant/i })).toHaveLength(1)
+ // Finalized dispute has no vote buttons but shows notice
+ expect(screen.getAllByText(/Outcome finalized/i)).toHaveLength(1)
+ })
+})
diff --git a/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx b/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx
index 6e8bfca..56a69b4 100644
--- a/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx
+++ b/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx
@@ -49,7 +49,7 @@ function DisputeCard({ dispute, onVote }: { dispute: Dispute; onVote: (id: numbe
}
return (
-
+
Settlement #{dispute.settlement_id}
{resolved && }
@@ -69,7 +69,15 @@ function DisputeCard({ dispute, onVote }: { dispute: Dispute; onVote: (id: numbe
- {!resolved && (
+ {/* When the outcome is finalized, show a clear status notice instead of
+ vote buttons. The buttons are not rendered at all so that there is no
+ possibility of submitting a now-meaningless vote transaction. */}
+ {resolved ? (
+
+ ✓
+ Outcome finalized — voting is closed for this dispute.
+
+ ) : (