diff --git a/package-lock.json b/package-lock.json index 72ff321..ee8f45c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@next/font": "^14.2.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", @@ -1188,6 +1189,114 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", diff --git a/package.json b/package.json index ff747e4..46f8975 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@next/font": "^14.2.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..8e7d221 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '@/contexts/auth-context' +import { AdminDashboard } from '@/components/admin/admin-dashboard' + +export default function AdminPage() { + const { user, isAdmin } = useAuth() + const router = useRouter() + + useEffect(() => { + // Redirect non-admin users to home page + if (user && !isAdmin) { + router.push('/') + } + // Redirect unauthenticated users to signin + if (!user) { + router.push('/signin') + } + }, [user, isAdmin, router]) + + // Show loading while checking auth + if (!user) { + return ( +
+
+
+

Loading...

+
+
+ ) + } + + // Show access denied for non-admin users + if (!isAdmin) { + return ( +
+
+
🚫
+

Access Denied

+

You don't have permission to access this page.

+ +
+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/src/components/admin/admin-dashboard.tsx b/src/components/admin/admin-dashboard.tsx new file mode 100644 index 0000000..042aa46 --- /dev/null +++ b/src/components/admin/admin-dashboard.tsx @@ -0,0 +1,360 @@ +"use client" + +import { useState, useEffect } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + AlertCircle, + CheckCircle, + Clock, + XCircle, + Copy, + Bell, + RefreshCw, + Search, + Filter +} from 'lucide-react' +import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin' +import { AdminFeature, AdminNotification, AdminStats } from '@/types/admin.types' +import { FeatureReviewDialog } from './feature-review-dialog' +import { AdminNotificationsPanel } from './admin-notifications-panel' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +export function AdminDashboard() { + const [pendingFeatures, setPendingFeatures] = useState([]) + const [notifications, setNotifications] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedFeature, setSelectedFeature] = useState(null) + const [showReviewDialog, setShowReviewDialog] = useState(false) + const [showNotifications, setShowNotifications] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + + // Load dashboard data + const loadDashboardData = async () => { + try { + setLoading(true) + setError(null) + + const [pendingData, notificationsData, statsData] = await Promise.all([ + adminApi.getPendingFeatures(), + adminApi.getNotifications(true, 10), // Get 10 unread notifications + adminApi.getFeatureStats() + ]) + + setPendingFeatures(pendingData) + setNotifications(notificationsData) + setStats(statsData) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load dashboard data') + console.error('Error loading dashboard data:', err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadDashboardData() + }, []) + + // Handle feature review + const handleFeatureReview = async (featureId: string, reviewData: any) => { + try { + await adminApi.reviewFeature(featureId, reviewData) + + // Remove the reviewed feature from pending list + setPendingFeatures(prev => prev.filter(f => f.id !== featureId)) + + // Reload stats + const newStats = await adminApi.getFeatureStats() + setStats(newStats) + + setShowReviewDialog(false) + setSelectedFeature(null) + } catch (err) { + console.error('Error reviewing feature:', err) + // Handle error (show toast notification, etc.) + } + } + + // Filter features based on search and status + const filteredFeatures = pendingFeatures.filter(feature => { + const matchesSearch = feature.name.toLowerCase().includes(searchQuery.toLowerCase()) || + feature.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + feature.template_title?.toLowerCase().includes(searchQuery.toLowerCase()) + + const matchesStatus = statusFilter === 'all' || feature.status === statusFilter + + return matchesSearch && matchesStatus + }) + + // Get status counts + const getStatusCount = (status: string) => { + return stats?.features.find(s => s.status === status)?.count || 0 + } + + const getUnreadNotificationCount = () => { + return stats?.notifications.unread || 0 + } + + if (loading) { + return ( +
+
+ + Loading admin dashboard... +
+
+ ) + } + + if (error) { + return ( +
+ + +
+ + Error loading dashboard +
+

{error}

+ +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Admin Dashboard

+

Manage feature approvals and system notifications

+
+
+ + +
+
+ + {/* Stats Cards */} +
+ + +
+ +
+

Pending

+

{getStatusCount('pending')}

+
+
+
+
+ + + +
+ +
+

Approved

+

{getStatusCount('approved')}

+
+
+
+
+ + + +
+ +
+

Rejected

+

{getStatusCount('rejected')}

+
+
+
+
+ + + +
+ +
+

Duplicates

+

{getStatusCount('duplicate')}

+
+
+
+
+
+ + {/* Main Content */} + + + + + Pending Review ({pendingFeatures.length}) + + + + All Features + + + + + {/* Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ +
+ + {/* Features List */} +
+ {filteredFeatures.length === 0 ? ( + + +
+ +

No features found

+

All features have been reviewed or no features match your filters.

+
+
+
+ ) : ( + filteredFeatures.map((feature) => ( + + +
+
+
+

{feature.name}

+ + {feature.status} + + + {feature.complexity} + +
+ + {feature.description && ( +

{feature.description}

+ )} + +
+ Template: {feature.template_title || 'Unknown'} + Submitted: {formatDate(feature.created_at)} + {feature.similarity_score && ( + Similarity: {(feature.similarity_score * 100).toFixed(1)}% + )} +
+ + {feature.admin_notes && ( +
+

+ Admin Notes: {feature.admin_notes} +

+
+ )} +
+ +
+ +
+
+
+
+ )) + )} +
+
+ + +

+ View and manage all features across different statuses. Use the filters above to narrow down results. +

+ {/* TODO: Implement all features view with pagination */} +
+
+ + {/* Review Dialog */} + {selectedFeature && ( + + )} + + {/* Notifications Panel */} + { + try { + await adminApi.markNotificationAsRead(id) + setNotifications(prev => prev.filter(n => n.id !== id)) + } catch (err) { + console.error('Error marking notification as read:', err) + } + }} + /> +
+ ) +} diff --git a/src/components/admin/admin-notifications-panel.tsx b/src/components/admin/admin-notifications-panel.tsx new file mode 100644 index 0000000..0278bff --- /dev/null +++ b/src/components/admin/admin-notifications-panel.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState } from 'react' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' +import { + Bell, + CheckCircle, + XCircle, + Copy, + Clock, + Check, + Trash2 +} from 'lucide-react' +import { AdminNotification } from '@/types/admin.types' +import { formatDate } from '@/lib/api/admin' + +interface AdminNotificationsPanelProps { + open: boolean + onOpenChange: (open: boolean) => void + notifications: AdminNotification[] + onNotificationRead: (id: string) => Promise +} + +export function AdminNotificationsPanel({ + open, + onOpenChange, + notifications, + onNotificationRead +}: AdminNotificationsPanelProps) { + const [markingAsRead, setMarkingAsRead] = useState(null) + + const handleMarkAsRead = async (id: string) => { + try { + setMarkingAsRead(id) + await onNotificationRead(id) + } catch (error) { + console.error('Error marking notification as read:', error) + } finally { + setMarkingAsRead(null) + } + } + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'new_feature': + return + case 'feature_reviewed': + return + default: + return + } + } + + const getNotificationColor = (type: string) => { + switch (type) { + case 'new_feature': + return 'border-l-blue-500' + case 'feature_reviewed': + return 'border-l-green-500' + default: + return 'border-l-gray-500' + } + } + + const unreadNotifications = notifications.filter(n => !n.is_read) + const readNotifications = notifications.filter(n => n.is_read) + + return ( + + + + + + Admin Notifications + {unreadNotifications.length > 0 && ( + + {unreadNotifications.length} + + )} + + + System notifications and feature review updates + + + +
+ {/* Unread Notifications */} + {unreadNotifications.length > 0 && ( +
+

+ Unread ({unreadNotifications.length}) +

+
+ {unreadNotifications.map((notification) => ( + + +
+
+ {getNotificationIcon(notification.type)} +
+

{notification.message}

+

+ {formatDate(notification.created_at)} +

+ {notification.reference_type && ( + + {notification.reference_type} + + )} +
+
+ +
+
+
+ ))} +
+
+ )} + + {/* Read Notifications */} + {readNotifications.length > 0 && ( +
+

+ Read ({readNotifications.length}) +

+
+ {readNotifications.map((notification) => ( + + +
+ {getNotificationIcon(notification.type)} +
+

{notification.message}

+

+ {formatDate(notification.created_at)} + {notification.read_at && ( + + • Read {formatDate(notification.read_at)} + + )} +

+ {notification.reference_type && ( + + {notification.reference_type} + + )} +
+
+
+
+ ))} +
+
+ )} + + {/* Empty State */} + {notifications.length === 0 && ( +
+ +

No notifications

+

+ You're all caught up! New notifications will appear here. +

+
+ )} +
+ + {/* Footer Actions */} + {notifications.length > 0 && ( +
+
+

+ {unreadNotifications.length} unread, {readNotifications.length} read +

+ +
+
+ )} +
+
+ ) +} diff --git a/src/components/admin/feature-review-dialog.tsx b/src/components/admin/feature-review-dialog.tsx new file mode 100644 index 0000000..5aac66e --- /dev/null +++ b/src/components/admin/feature-review-dialog.tsx @@ -0,0 +1,311 @@ +"use client" + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { + CheckCircle, + XCircle, + Copy, + AlertTriangle, + Search, + ExternalLink +} from 'lucide-react' +import { AdminFeature, FeatureSimilarity, FeatureReviewData } from '@/types/admin.types' +import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin' + +interface FeatureReviewDialogProps { + feature: AdminFeature + open: boolean + onOpenChange: (open: boolean) => void + onReview: (featureId: string, reviewData: FeatureReviewData) => Promise +} + +export function FeatureReviewDialog({ + feature, + open, + onOpenChange, + onReview +}: FeatureReviewDialogProps) { + const [status, setStatus] = useState<'approved' | 'rejected' | 'duplicate'>('approved') + const [notes, setNotes] = useState('') + const [canonicalFeatureId, setCanonicalFeatureId] = useState('') + const [similarFeatures, setSimilarFeatures] = useState([]) + const [loadingSimilar, setLoadingSimilar] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + // Load similar features when dialog opens + useEffect(() => { + if (open && feature.name) { + loadSimilarFeatures(feature.name) + } + }, [open, feature.name]) + + const loadSimilarFeatures = async (query: string) => { + try { + setLoadingSimilar(true) + const features = await adminApi.findSimilarFeatures(query, 0.7, 5) + setSimilarFeatures(features) + } catch (error) { + console.error('Error loading similar features:', error) + } finally { + setLoadingSimilar(false) + } + } + + const handleSubmit = async () => { + if (!status) return + + const reviewData: FeatureReviewData = { + status, + notes: notes.trim() || undefined, + canonical_feature_id: status === 'duplicate' ? canonicalFeatureId : undefined, + admin_reviewed_by: 'admin' // TODO: Get from auth context + } + + try { + setSubmitting(true) + await onReview(feature.id, reviewData) + } catch (error) { + console.error('Error reviewing feature:', error) + } finally { + setSubmitting(false) + } + } + + const handleStatusChange = (newStatus: string) => { + setStatus(newStatus as 'approved' | 'rejected' | 'duplicate') + if (newStatus !== 'duplicate') { + setCanonicalFeatureId('') + } + } + + const filteredSimilarFeatures = similarFeatures.filter(f => + f.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + return ( + + + + Review Feature: {feature.name} + + Review and approve, reject, or mark as duplicate this custom feature submission. + + + +
+ {/* Feature Details */} + + +
+
+

{feature.name}

+ + {feature.status} + + + {feature.complexity} + +
+ + {feature.description && ( +

{feature.description}

+ )} + +
+
+ Template: {feature.template_title || 'Unknown'} +
+
+ Submitted: {formatDate(feature.created_at)} +
+ {feature.similarity_score && ( +
+ Similarity Score: {(feature.similarity_score * 100).toFixed(1)}% +
+ )} +
+ Usage Count: {feature.usage_count} +
+
+ + {feature.business_rules && ( +
+

Business Rules:

+
+                      {JSON.stringify(feature.business_rules, null, 2)}
+                    
+
+ )} + + {feature.technical_requirements && ( +
+

Technical Requirements:

+
+                      {JSON.stringify(feature.technical_requirements, null, 2)}
+                    
+
+ )} +
+
+
+ + {/* Similar Features */} + + +
+

Similar Features

+
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-4 py-2 border rounded-md text-sm" + /> +
+
+ + {loadingSimilar ? ( +
+
+

Loading similar features...

+
+ ) : filteredSimilarFeatures.length > 0 ? ( +
+ {filteredSimilarFeatures.map((similar) => ( +
+
+
+ {similar.name} + + {similar.complexity} + + + {similar.match_type} ({(similar.score * 100).toFixed(1)}%) + +
+

{similar.feature_type}

+
+ +
+ ))} +
+ ) : ( +
+ +

No similar features found

+
+ )} +
+
+ + {/* Review Form */} +
+
+ + +
+ + {status === 'duplicate' && ( +
+ + setCanonicalFeatureId(e.target.value)} + placeholder="Enter the ID of the canonical feature" + className="w-full px-3 py-2 border rounded-md" + /> +

+ Select a similar feature above or enter the canonical feature ID manually +

+
+ )} + +
+ +