Skip to content

Commit 9500a85

Browse files
authored
Merge pull request #257 from darcszn/feat/policy-dashboard
feat: policy dashboard — paginated list, filters, action flows
2 parents e3b2d08 + 41653e6 commit 9500a85

9 files changed

Lines changed: 1172 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PolicyDashboard } from '@/features/policies/components/PolicyDashboard';
2+
3+
export const metadata = { title: 'My Policies' };
4+
5+
export default function PolicyDashboardPage() {
6+
return (
7+
<main className="container mx-auto px-4 py-8 max-w-6xl">
8+
<h1 className="text-2xl font-bold text-gray-900 mb-6">My Policies</h1>
9+
<PolicyDashboard />
10+
</main>
11+
);
12+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { notFound } from 'next/navigation';
2+
import Link from 'next/link';
3+
import { getPolicy } from '@/lib/api/chain';
4+
import { formatXlm } from '@/features/policies/components/PolicyItem';
5+
import { SECS_PER_LEDGER } from '@/lib/schemas/vote';
6+
7+
interface Props {
8+
params: Promise<{ holder: string; policyId: string }>;
9+
}
10+
11+
export default async function PolicyDetailPage({ params }: Props) {
12+
const { holder, policyId } = await params;
13+
const id = parseInt(policyId, 10);
14+
if (isNaN(id)) notFound();
15+
16+
const policy = await getPolicy(decodeURIComponent(holder), id).catch(() => null);
17+
if (!policy) notFound();
18+
19+
// policy is Record<string, unknown> from chain read — cast to known shape
20+
const p = policy as {
21+
policy_id: number;
22+
policy_type: string;
23+
region: string;
24+
is_active: boolean;
25+
coverage: string;
26+
premium: string;
27+
start_ledger: number;
28+
end_ledger: number;
29+
strike_count: number;
30+
};
31+
32+
const statusLabel = p.is_active ? 'Active' : 'Inactive';
33+
34+
return (
35+
<main className="container mx-auto px-4 py-8 max-w-2xl space-y-6">
36+
<nav aria-label="Breadcrumb" className="text-sm text-gray-500">
37+
<Link href="/dashboard" className="hover:underline text-blue-600">My Policies</Link>
38+
{' / '}
39+
<span>Policy #{p.policy_id}</span>
40+
</nav>
41+
42+
<div className="rounded-xl border border-gray-200 bg-white p-6 space-y-4 shadow-sm">
43+
<div className="flex items-center justify-between">
44+
<h1 className="text-xl font-bold text-gray-900">Policy #{p.policy_id}</h1>
45+
<span className={`rounded-full px-3 py-1 text-xs font-medium ${p.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
46+
{statusLabel}
47+
</span>
48+
</div>
49+
50+
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
51+
<Detail label="Type" value={p.policy_type} />
52+
<Detail label="Region" value={p.region} />
53+
<Detail label="Coverage" value={`${formatXlm(p.coverage)} XLM`} />
54+
<Detail label="Premium / yr" value={`${formatXlm(p.premium)} XLM`} />
55+
<Detail label="Start ledger" value={String(p.start_ledger)} />
56+
<Detail label="End ledger" value={String(p.end_ledger)} />
57+
<Detail label="Strike count" value={String(p.strike_count)} />
58+
</dl>
59+
60+
<p className="text-xs text-gray-400">
61+
ⓘ This data is read directly from the Soroban ledger (no indexer lag).
62+
Amounts are in XLM (7 decimal places). Ledger timing: ~{SECS_PER_LEDGER}s per ledger.
63+
</p>
64+
</div>
65+
66+
<Link
67+
href={`/policy/${encodeURIComponent(holder)}/${policyId}/claim`}
68+
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
69+
>
70+
File a claim
71+
</Link>
72+
</main>
73+
);
74+
}
75+
76+
function Detail({ label, value }: { label: string; value: string }) {
77+
return (
78+
<div>
79+
<dt className="text-xs text-gray-500">{label}</dt>
80+
<dd className="font-medium text-gray-900 tabular-nums">{value}</dd>
81+
</div>
82+
);
83+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { z } from 'zod';
2+
import { getConfig } from '@/config/env';
3+
4+
// ── DTO schemas (mirrors backend/src/dto/policy.dto.ts) ──────────────────────
5+
6+
export const ClaimSummaryDtoSchema = z.object({
7+
claim_id: z.number(),
8+
amount: z.string(),
9+
status: z.enum(['Processing', 'Approved', 'Rejected']),
10+
approve_votes: z.number(),
11+
reject_votes: z.number(),
12+
voting_deadline_ledger: z.number().optional(),
13+
_link: z.string(),
14+
});
15+
16+
export const PolicyDtoSchema = z.object({
17+
holder: z.string(),
18+
policy_id: z.number(),
19+
policy_type: z.enum(['Auto', 'Health', 'Property']),
20+
region: z.enum(['Low', 'Medium', 'High']),
21+
is_active: z.boolean(),
22+
coverage_summary: z.object({
23+
coverage_amount: z.string(),
24+
premium_amount: z.string(),
25+
currency: z.literal('XLM'),
26+
decimals: z.literal(7),
27+
}),
28+
expiry_countdown: z.object({
29+
start_ledger: z.number(),
30+
end_ledger: z.number(),
31+
ledgers_remaining: z.number(),
32+
avg_ledger_close_seconds: z.literal(5),
33+
}),
34+
claims: z.array(ClaimSummaryDtoSchema),
35+
_link: z.string(),
36+
});
37+
38+
export const PolicyListDtoSchema = z.object({
39+
data: z.array(PolicyDtoSchema),
40+
next_cursor: z.string().nullable(),
41+
total: z.number(),
42+
});
43+
44+
export type PolicyDto = z.infer<typeof PolicyDtoSchema>;
45+
export type PolicyListDto = z.infer<typeof PolicyListDtoSchema>;
46+
export type PolicyStatusFilter = 'active' | 'expired' | 'all';
47+
export type PolicySortField = 'expiry' | 'coverage' | 'premium';
48+
49+
export interface PolicyListParams {
50+
holder: string;
51+
status?: PolicyStatusFilter;
52+
sort?: PolicySortField;
53+
after?: string;
54+
limit?: number;
55+
}
56+
57+
export class PolicyListError extends Error {
58+
constructor(
59+
public code: string,
60+
message: string,
61+
) {
62+
super(message);
63+
this.name = 'PolicyListError';
64+
}
65+
}
66+
67+
export async function fetchPolicies(
68+
params: PolicyListParams,
69+
signal?: AbortSignal,
70+
): Promise<PolicyListDto> {
71+
const { apiUrl } = getConfig();
72+
const qs = new URLSearchParams({ holder: params.holder });
73+
if (params.status && params.status !== 'all') qs.set('status', params.status);
74+
if (params.after) qs.set('after', params.after);
75+
if (params.limit) qs.set('limit', String(params.limit));
76+
77+
const res = await fetch(`${apiUrl}/api/policies?${qs}`, { signal });
78+
if (!res.ok) {
79+
const err = await res.json().catch(() => ({ code: 'FETCH_FAILED', message: 'Failed to load policies' }));
80+
throw new PolicyListError(err.code ?? 'FETCH_FAILED', err.message ?? 'Failed to load policies');
81+
}
82+
const json: unknown = await res.json();
83+
const parsed = PolicyListDtoSchema.safeParse(json);
84+
if (!parsed.success) {
85+
throw new PolicyListError('PARSE_ERROR', `Invalid response: ${parsed.error.message}`);
86+
}
87+
return parsed.data;
88+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
'use client';
2+
3+
import { useCallback, useState } from 'react';
4+
import { useWallet } from '@/hooks/use-wallet';
5+
import { useLatestLedger } from '@/hooks/use-latest-ledger';
6+
import { getConfig } from '@/config/env';
7+
import { usePolicies } from '../hooks/usePolicies';
8+
import { PolicyCard, PolicyRow } from './PolicyItem';
9+
import { PolicyListSkeleton, PolicyEmptyState, PolicyErrorState } from './PolicyStates';
10+
import { RenewModal } from './RenewModal';
11+
import { TerminateModal } from './TerminateModal';
12+
import type { PolicyDto, PolicyStatusFilter, PolicySortField } from '../api';
13+
14+
const SORT_OPTIONS: { value: PolicySortField; label: string }[] = [
15+
{ value: 'expiry', label: 'Expiry (soonest)' },
16+
{ value: 'coverage', label: 'Coverage (highest)' },
17+
{ value: 'premium', label: 'Premium (highest)' },
18+
];
19+
20+
export function PolicyDashboard() {
21+
const { address } = useWallet();
22+
const { network } = getConfig();
23+
const currentLedger = useLatestLedger();
24+
25+
const [status, setStatus] = useState<PolicyStatusFilter>('all');
26+
const [sort, setSort] = useState<PolicySortField>('expiry');
27+
const [layout, setLayout] = useState<'row' | 'card'>('row');
28+
29+
const [renewTarget, setRenewTarget] = useState<PolicyDto | null>(null);
30+
const [terminateTarget, setTerminateTarget] = useState<PolicyDto | null>(null);
31+
32+
const { policies, total, pageIndex, hasNextPage, hasPrevPage, loading, error, goToPage, retry } =
33+
usePolicies(address, network, status, sort);
34+
35+
const handleRenew = useCallback((policy: PolicyDto) => setRenewTarget(policy), []);
36+
const handleTerminate = useCallback((policy: PolicyDto) => setTerminateTarget(policy), []);
37+
38+
const totalPages = Math.max(1, Math.ceil(total / 20));
39+
40+
return (
41+
<section aria-label="My policies" className="space-y-4">
42+
{/* ── Toolbar ─────────────────────────────────────────────────── */}
43+
<div className="flex flex-wrap items-end gap-3">
44+
{/* Status filter */}
45+
<label className="flex flex-col gap-1 text-xs font-medium text-gray-600">
46+
Status
47+
<select
48+
value={status}
49+
onChange={(e) => { setStatus(e.target.value as PolicyStatusFilter); goToPage(0); }}
50+
className="rounded border border-gray-300 px-2 py-1.5 text-sm min-h-[44px] focus:outline-none focus:ring-2 focus:ring-blue-500"
51+
aria-label="Filter by policy status"
52+
>
53+
<option value="all">All</option>
54+
<option value="active">Active</option>
55+
<option value="expired">Expired</option>
56+
</select>
57+
</label>
58+
59+
{/* Sort */}
60+
<label className="flex flex-col gap-1 text-xs font-medium text-gray-600">
61+
Sort by
62+
<select
63+
value={sort}
64+
onChange={(e) => setSort(e.target.value as PolicySortField)}
65+
className="rounded border border-gray-300 px-2 py-1.5 text-sm min-h-[44px] focus:outline-none focus:ring-2 focus:ring-blue-500"
66+
aria-label="Sort policies"
67+
>
68+
{SORT_OPTIONS.map((o) => (
69+
<option key={o.value} value={o.value}>{o.label}</option>
70+
))}
71+
</select>
72+
</label>
73+
74+
{/* Layout toggle */}
75+
<div
76+
role="group"
77+
aria-label="Layout"
78+
className="ml-auto flex rounded border border-gray-300 overflow-hidden"
79+
>
80+
<LayoutButton active={layout === 'row'} onClick={() => setLayout('row')} label="Table view" icon="☰" />
81+
<LayoutButton active={layout === 'card'} onClick={() => setLayout('card')} label="Card view" icon="⊞" />
82+
</div>
83+
</div>
84+
85+
{/* ── Count ───────────────────────────────────────────────────── */}
86+
{!loading && !error && (
87+
<p className="text-xs text-gray-500" aria-live="polite">
88+
{total} {total === 1 ? 'policy' : 'policies'}
89+
{status !== 'all' ? ` · ${status}` : ''}
90+
</p>
91+
)}
92+
93+
{/* ── Content ─────────────────────────────────────────────────── */}
94+
{loading ? (
95+
<PolicyListSkeleton layout={layout} />
96+
) : error ? (
97+
<PolicyErrorState message={error} onRetry={retry} />
98+
) : policies.length === 0 ? (
99+
<PolicyEmptyState filter={status === 'all' ? 'all' : status} />
100+
) : layout === 'card' ? (
101+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
102+
{policies.map((p) => (
103+
<PolicyCard
104+
key={`${p.holder}:${p.policy_id}`}
105+
policy={p}
106+
onRenew={handleRenew}
107+
onTerminate={handleTerminate}
108+
currentLedger={currentLedger}
109+
/>
110+
))}
111+
</div>
112+
) : (
113+
<div className="overflow-x-auto rounded-lg border border-gray-200">
114+
<table className="min-w-full text-sm">
115+
<thead className="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
116+
<tr>
117+
<th className="px-4 py-3 text-left">Policy</th>
118+
<th className="px-4 py-3 text-left">Status</th>
119+
<th className="px-4 py-3 text-right">Coverage</th>
120+
<th className="px-4 py-3 text-right">Premium / yr</th>
121+
<th className="px-4 py-3 text-left">Expiry</th>
122+
<th className="px-4 py-3 text-left">Actions</th>
123+
</tr>
124+
</thead>
125+
<tbody>
126+
{policies.map((p) => (
127+
<PolicyRow
128+
key={`${p.holder}:${p.policy_id}`}
129+
policy={p}
130+
onRenew={handleRenew}
131+
onTerminate={handleTerminate}
132+
currentLedger={currentLedger}
133+
/>
134+
))}
135+
</tbody>
136+
</table>
137+
</div>
138+
)}
139+
140+
{/* ── Pagination ──────────────────────────────────────────────── */}
141+
{!loading && !error && totalPages > 1 && (
142+
<nav aria-label="Policy pages" className="flex items-center justify-center gap-4">
143+
<button
144+
type="button"
145+
onClick={() => goToPage(pageIndex - 1)}
146+
disabled={!hasPrevPage}
147+
aria-label="Previous page"
148+
className="min-h-[44px] min-w-[44px] rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
149+
>
150+
Previous
151+
</button>
152+
<span aria-live="polite" className="text-sm text-gray-700">
153+
Page {pageIndex + 1} of {totalPages}
154+
</span>
155+
<button
156+
type="button"
157+
onClick={() => goToPage(pageIndex + 1)}
158+
disabled={!hasNextPage}
159+
aria-label="Next page"
160+
className="min-h-[44px] min-w-[44px] rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
161+
>
162+
Next
163+
</button>
164+
</nav>
165+
)}
166+
167+
{/* ── Action modals ───────────────────────────────────────────── */}
168+
{renewTarget && (
169+
<RenewModal policy={renewTarget} onClose={() => setRenewTarget(null)} />
170+
)}
171+
{terminateTarget && (
172+
<TerminateModal policy={terminateTarget} onClose={() => setTerminateTarget(null)} />
173+
)}
174+
</section>
175+
);
176+
}
177+
178+
function LayoutButton({
179+
active,
180+
onClick,
181+
label,
182+
icon,
183+
}: {
184+
active: boolean;
185+
onClick: () => void;
186+
label: string;
187+
icon: string;
188+
}) {
189+
return (
190+
<button
191+
type="button"
192+
onClick={onClick}
193+
aria-label={label}
194+
aria-pressed={active}
195+
className={[
196+
'px-3 py-2 text-sm min-h-[44px] focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500',
197+
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50',
198+
].join(' ')}
199+
>
200+
<span aria-hidden="true">{icon}</span>
201+
</button>
202+
);
203+
}

0 commit comments

Comments
 (0)