diff --git a/web/src/components/treasury/TreasuryOverview.tsx b/web/src/components/treasury/TreasuryOverview.tsx index 3798ab6f..d49819cf 100644 --- a/web/src/components/treasury/TreasuryOverview.tsx +++ b/web/src/components/treasury/TreasuryOverview.tsx @@ -4,18 +4,21 @@ import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useTranslation } from 'react-i18next'; -import { - DollarSign, - TrendingUp, - TrendingDown, +import { useTreasury } from '@/hooks/useTreasury'; +import { + DollarSign, + TrendingUp, + TrendingDown, PieChart, Activity, AlertCircle, CheckCircle, Clock, ArrowUpRight, - ArrowDownRight + ArrowDownRight, + Loader2 } from 'lucide-react'; interface TreasuryMetrics { @@ -38,14 +41,7 @@ interface BudgetCategory { export const TreasuryOverview: React.FC = () => { const { t } = useTranslation(); - const [metrics, setMetrics] = useState({ - totalBalance: 2500000, - monthlyIncome: 150000, - monthlyExpenses: 120000, - pendingProposals: 8, - approvedBudget: 1800000, - healthScore: 85 - }); + const { metrics, proposals, loading, error } = useTreasury(); const [categories] = useState([ { id: '1', name: 'Development', allocated: 500000, spent: 320000, remaining: 180000, color: 'bg-blue-500' }, @@ -66,8 +62,39 @@ export const TreasuryOverview: React.FC = () => { const healthStatus = getHealthStatus(metrics.healthScore); const HealthIcon = healthStatus.icon; + if (loading) { + return ( +
+ + Loading treasury data from blockchain... +
+ ); + } + + if (error) { + return ( + + + + Failed to load treasury data: {error} + + + ); + } + return (
+ {/* Live Data Badge */} +
+ + + Live Blockchain Data + + + {proposals.length} active proposals • {metrics.totalBalance.toFixed(2)} PZKW in treasury + +
+ {/* Treasury Health Score */} diff --git a/web/src/hooks/useGovernance.ts b/web/src/hooks/useGovernance.ts new file mode 100644 index 00000000..f746deed --- /dev/null +++ b/web/src/hooks/useGovernance.ts @@ -0,0 +1,121 @@ +import { useState, useEffect } from 'react'; +import { usePolkadot } from '@/contexts/PolkadotContext'; + +export interface Proposal { + id: string; + proposalIndex: number; + hash: string; + proposer: string; + value: string; + beneficiary: string; + bond: string; + status: 'active' | 'approved' | 'rejected'; + method: string; + createdAt: number; +} + +export interface Referendum { + id: string; + index: number; + hash: string; + threshold: string; + delay: number; + end: number; + voteCount: number; + ayeVotes: string; + nayVotes: string; + status: 'ongoing' | 'passed' | 'failed'; +} + +export function useGovernance() { + const { api, isConnected } = usePolkadot(); + const [proposals, setProposals] = useState([]); + const [referenda, setReferenda] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!api || !isConnected) { + setLoading(false); + return; + } + + const fetchGovernanceData = async () => { + try { + setLoading(true); + setError(null); + + // Fetch Treasury Proposals + const proposalsData = await api.query.treasury?.proposals?.entries(); + if (proposalsData) { + const parsedProposals: Proposal[] = proposalsData.map(([key, value]: any) => { + const proposalIndex = key.args[0].toNumber(); + const proposal = value.unwrap(); + + return { + id: `prop-${proposalIndex}`, + proposalIndex, + hash: key.toHex(), + proposer: proposal.proposer.toString(), + value: proposal.value.toString(), + beneficiary: proposal.beneficiary.toString(), + bond: proposal.bond.toString(), + status: 'active', + method: 'treasury.approveProposal', + createdAt: Date.now() + }; + }); + setProposals(parsedProposals); + } + + // Fetch Democracy Referenda + const referendaData = await api.query.democracy?.referendumInfoOf?.entries(); + if (referendaData) { + const parsedReferenda: Referendum[] = referendaData.map(([key, value]: any) => { + const index = key.args[0].toNumber(); + const info = value.unwrap(); + + if (info.isOngoing) { + const ongoing = info.asOngoing; + return { + id: `ref-${index}`, + index, + hash: key.toHex(), + threshold: ongoing.threshold.toString(), + delay: ongoing.delay.toNumber(), + end: ongoing.end.toNumber(), + voteCount: 0, + ayeVotes: ongoing.tally?.ayes?.toString() || '0', + nayVotes: ongoing.tally?.nays?.toString() || '0', + status: 'ongoing' as const + }; + } + + return null; + }).filter(Boolean) as Referendum[]; + + setReferenda(parsedReferenda); + } + + } catch (err) { + console.error('Error fetching governance data:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch governance data'); + } finally { + setLoading(false); + } + }; + + fetchGovernanceData(); + + // Subscribe to updates + const interval = setInterval(fetchGovernanceData, 30000); // Refresh every 30 seconds + return () => clearInterval(interval); + }, [api, isConnected]); + + return { + proposals, + referenda, + loading, + error + }; +} diff --git a/web/src/hooks/useTreasury.ts b/web/src/hooks/useTreasury.ts new file mode 100644 index 00000000..3dcb4ac8 --- /dev/null +++ b/web/src/hooks/useTreasury.ts @@ -0,0 +1,119 @@ +import { useState, useEffect } from 'react'; +import { usePolkadot } from '@/contexts/PolkadotContext'; + +export interface TreasuryMetrics { + totalBalance: number; + monthlyIncome: number; + monthlyExpenses: number; + pendingProposals: number; + approvedBudget: number; + healthScore: number; +} + +export interface TreasuryProposal { + id: string; + index: number; + proposer: string; + beneficiary: string; + value: string; + bond: string; + status: 'pending' | 'approved' | 'rejected'; +} + +export function useTreasury() { + const { api, isConnected } = usePolkadot(); + const [metrics, setMetrics] = useState({ + totalBalance: 0, + monthlyIncome: 0, + monthlyExpenses: 0, + pendingProposals: 0, + approvedBudget: 0, + healthScore: 0 + }); + const [proposals, setProposals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!api || !isConnected) { + setLoading(false); + return; + } + + const fetchTreasuryData = async () => { + try { + setLoading(true); + setError(null); + + // Get treasury account balance + const treasuryAccount = await api.query.treasury?.treasury?.(); + let totalBalance = 0; + + if (treasuryAccount) { + totalBalance = parseInt(treasuryAccount.toString()) / 1e12; // Convert from planck to tokens + } + + // Fetch all treasury proposals + const proposalsData = await api.query.treasury?.proposals?.entries(); + const proposalsList: TreasuryProposal[] = []; + let approvedBudget = 0; + let pendingCount = 0; + + if (proposalsData) { + proposalsData.forEach(([key, value]: any) => { + const index = key.args[0].toNumber(); + const proposal = value.unwrap(); + const valueAmount = parseInt(proposal.value.toString()) / 1e12; + + const proposalItem: TreasuryProposal = { + id: `treasury-${index}`, + index, + proposer: proposal.proposer.toString(), + beneficiary: proposal.beneficiary.toString(), + value: proposal.value.toString(), + bond: proposal.bond.toString(), + status: 'pending' + }; + + proposalsList.push(proposalItem); + pendingCount++; + approvedBudget += valueAmount; + }); + } + + // Calculate health score (simplified) + const healthScore = Math.min(100, Math.round((totalBalance / (approvedBudget || 1)) * 100)); + + setMetrics({ + totalBalance, + monthlyIncome: 0, // This would require historical data + monthlyExpenses: 0, // This would require historical data + pendingProposals: pendingCount, + approvedBudget, + healthScore: isNaN(healthScore) ? 0 : healthScore + }); + + setProposals(proposalsList); + + } catch (err) { + console.error('Error fetching treasury data:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch treasury data'); + } finally { + setLoading(false); + } + }; + + fetchTreasuryData(); + + // Subscribe to updates + const interval = setInterval(fetchTreasuryData, 30000); // Refresh every 30 seconds + return () => clearInterval(interval); + }, [api, isConnected]); + + return { + metrics, + proposals, + loading, + error + }; +} diff --git a/web/supabase/migrations/005_create_forum_tables.sql b/web/supabase/migrations/005_create_forum_tables.sql new file mode 100644 index 00000000..6592cbe4 --- /dev/null +++ b/web/supabase/migrations/005_create_forum_tables.sql @@ -0,0 +1,216 @@ +-- Forum Tables for Pezkuwi Governance + +-- Admin Announcements Table +CREATE TABLE IF NOT EXISTS public.admin_announcements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'info', -- 'info', 'warning', 'success', 'critical' + is_active BOOLEAN DEFAULT true, + priority INTEGER DEFAULT 0, -- Higher priority shows first + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Forum Categories +CREATE TABLE IF NOT EXISTS public.forum_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + icon TEXT, + color TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Forum Discussions +CREATE TABLE IF NOT EXISTS public.forum_discussions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID REFERENCES public.forum_categories(id) ON DELETE SET NULL, + proposal_id TEXT, -- Link to blockchain proposal if applicable + title TEXT NOT NULL, + content TEXT NOT NULL, + author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + author_name TEXT NOT NULL, + author_address TEXT, -- Blockchain address + is_pinned BOOLEAN DEFAULT false, + is_locked BOOLEAN DEFAULT false, + views_count INTEGER DEFAULT 0, + replies_count INTEGER DEFAULT 0, + tags TEXT[], + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Forum Replies +CREATE TABLE IF NOT EXISTS public.forum_replies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + discussion_id UUID REFERENCES public.forum_discussions(id) ON DELETE CASCADE, + parent_reply_id UUID REFERENCES public.forum_replies(id) ON DELETE CASCADE, -- For nested replies + content TEXT NOT NULL, + author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + author_name TEXT NOT NULL, + author_address TEXT, + is_edited BOOLEAN DEFAULT false, + edited_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Discussion Reactions (upvotes/downvotes) +CREATE TABLE IF NOT EXISTS public.forum_reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + discussion_id UUID REFERENCES public.forum_discussions(id) ON DELETE CASCADE, + reply_id UUID REFERENCES public.forum_replies(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + reaction_type TEXT NOT NULL, -- 'upvote', 'downvote', 'helpful', etc. + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(discussion_id, user_id, reaction_type), + UNIQUE(reply_id, user_id, reaction_type) +); + +-- Create Indexes +CREATE INDEX IF NOT EXISTS idx_announcements_active ON public.admin_announcements(is_active, priority DESC, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_discussions_category ON public.forum_discussions(category_id); +CREATE INDEX IF NOT EXISTS idx_discussions_proposal ON public.forum_discussions(proposal_id); +CREATE INDEX IF NOT EXISTS idx_discussions_pinned ON public.forum_discussions(is_pinned DESC, last_activity_at DESC); +CREATE INDEX IF NOT EXISTS idx_replies_discussion ON public.forum_replies(discussion_id, created_at); +CREATE INDEX IF NOT EXISTS idx_reactions_discussion ON public.forum_reactions(discussion_id); +CREATE INDEX IF NOT EXISTS idx_reactions_reply ON public.forum_reactions(reply_id); + +-- RLS Policies + +-- Admin Announcements +ALTER TABLE public.admin_announcements ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can view active announcements" + ON public.admin_announcements FOR SELECT + USING (is_active = true AND (expires_at IS NULL OR expires_at > NOW())); + +CREATE POLICY "Admins can manage announcements" + ON public.admin_announcements FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM public.admin_roles + WHERE user_id = auth.uid() + ) + ); + +-- Forum Categories +ALTER TABLE public.forum_categories ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can view active categories" + ON public.forum_categories FOR SELECT + USING (is_active = true); + +CREATE POLICY "Admins can manage categories" + ON public.forum_categories FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM public.admin_roles + WHERE user_id = auth.uid() + ) + ); + +-- Forum Discussions +ALTER TABLE public.forum_discussions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can view discussions" + ON public.forum_discussions FOR SELECT + USING (true); + +CREATE POLICY "Authenticated users can create discussions" + ON public.forum_discussions FOR INSERT + WITH CHECK (auth.uid() IS NOT NULL); + +CREATE POLICY "Authors and admins can update discussions" + ON public.forum_discussions FOR UPDATE + USING ( + author_id = auth.uid() OR + EXISTS ( + SELECT 1 FROM public.admin_roles + WHERE user_id = auth.uid() + ) + ); + +CREATE POLICY "Authors and admins can delete discussions" + ON public.forum_discussions FOR DELETE + USING ( + author_id = auth.uid() OR + EXISTS ( + SELECT 1 FROM public.admin_roles + WHERE user_id = auth.uid() + ) + ); + +-- Forum Replies +ALTER TABLE public.forum_replies ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can view replies" + ON public.forum_replies FOR SELECT + USING (true); + +CREATE POLICY "Authenticated users can create replies" + ON public.forum_replies FOR INSERT + WITH CHECK (auth.uid() IS NOT NULL); + +CREATE POLICY "Authors and admins can update replies" + ON public.forum_replies FOR UPDATE + USING ( + author_id = auth.uid() OR + EXISTS ( + SELECT 1 FROM public.admin_roles + WHERE user_id = auth.uid() + ) + ); + +CREATE POLICY "Authors and admins can delete replies" + ON public.forum_replies FOR DELETE + USING ( + author_id = auth.uid() OR + EXISTS ( + SELECT 1 FROM public.admin_roles + WHERE user_id = auth.uid() + ) + ); + +-- Forum Reactions +ALTER TABLE public.forum_reactions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can view reactions" + ON public.forum_reactions FOR SELECT + USING (true); + +CREATE POLICY "Authenticated users can manage own reactions" + ON public.forum_reactions FOR ALL + USING (user_id = auth.uid()); + +-- Insert Default Categories +INSERT INTO public.forum_categories (name, description, icon, color, display_order) VALUES + ('Treasury', 'Discussions about treasury proposals and funding', '💰', '#10B981', 1), + ('Technical', 'Technical discussions and protocol upgrades', '⚙️', '#3B82F6', 2), + ('Governance', 'Governance proposals and voting discussions', '🗳️', '#8B5CF6', 3), + ('Community', 'Community initiatives and general discussions', '👥', '#F59E0B', 4), + ('Support', 'Help and support for using Pezkuwi', '❓', '#6B7280', 5) +ON CONFLICT (name) DO NOTHING; + +-- Function to update last_activity +CREATE OR REPLACE FUNCTION update_discussion_activity() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE public.forum_discussions + SET + last_activity_at = NOW(), + replies_count = replies_count + 1 + WHERE id = NEW.discussion_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER on_reply_created + AFTER INSERT ON public.forum_replies + FOR EACH ROW + EXECUTE FUNCTION update_discussion_activity();