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 (
+
+ )
+ }
+
+ // Show access denied for non-admin users
+ if (!isAdmin) {
+ return (
+
+
+
🚫
+
Access Denied
+
You don't have permission to access this page.
+
router.push('/')}
+ className="px-4 py-2 bg-orange-500 text-black rounded-md hover:bg-orange-600 transition-colors"
+ >
+ Go to Home
+
+
+
+ )
+ }
+
+ 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}
+
+
+ Retry
+
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Admin Dashboard
+
Manage feature approvals and system notifications
+
+
+ setShowNotifications(true)}
+ className="relative"
+ >
+
+ Notifications
+ {getUnreadNotificationCount() > 0 && (
+
+ {getUnreadNotificationCount()}
+
+ )}
+
+
+
+ Refresh
+
+
+
+
+ {/* 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"
+ />
+
+
+
+
+
+
+
+ All Status
+ Pending
+ Approved
+ Rejected
+ Duplicate
+
+
+
+
+ {/* 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}
+
+
+ )}
+
+
+
+ {
+ setSelectedFeature(feature)
+ setShowReviewDialog(true)
+ }}
+ >
+ Review
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ 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}
+
+ )}
+
+
+
handleMarkAsRead(notification.id)}
+ disabled={markingAsRead === notification.id}
+ className="ml-2"
+ >
+ {markingAsRead === notification.id ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* 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
+
+
{
+ // TODO: Implement mark all as read
+ console.log('Mark all as read')
+ }}
+ >
+
+ Mark All 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}
+
+
setCanonicalFeatureId(similar.id)}
+ disabled={status !== 'duplicate'}
+ >
+
+ Select as Duplicate
+
+
+ ))}
+
+ ) : (
+
+
+
No similar features found
+
+ )}
+
+
+
+ {/* Review Form */}
+
+
+
Review Decision
+
+
+
+
+
+
+
+
+ Approve
+
+
+
+
+
+ Reject
+
+
+
+
+
+ Mark as Duplicate
+
+
+
+
+
+
+ {status === 'duplicate' && (
+
+
Canonical Feature ID
+
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
+
+
+ )}
+
+
+ Admin Notes (Optional)
+
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {submitting ? (
+ <>
+
+ Submitting...
+ >
+ ) : (
+ `Submit ${status.charAt(0).toUpperCase() + status.slice(1)}`
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/apis/authApiClients.tsx b/src/components/apis/authApiClients.tsx
index 6505b4c..4cfb328 100644
--- a/src/components/apis/authApiClients.tsx
+++ b/src/components/apis/authApiClients.tsx
@@ -34,12 +34,10 @@ export const logout = async () => {
console.error('Logout API call failed:', error);
// Continue with logout even if API call fails
} finally {
- // Always clear tokens and redirect
+ // Always clear tokens
clearTokens();
// Clear any other user data
safeLocalStorage.removeItem('codenuk_user');
- // Redirect to signin page
- safeRedirect('/signin');
}
};
@@ -83,7 +81,8 @@ const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
clearTokens();
- window.location.href = '/auth';
+ safeLocalStorage.removeItem('codenuk_user');
+ window.location.href = '/signin';
return Promise.reject(refreshError);
}
}
diff --git a/src/components/auth/signin-form.tsx b/src/components/auth/signin-form.tsx
index 1154f34..62062ee 100644
--- a/src/components/auth/signin-form.tsx
+++ b/src/components/auth/signin-form.tsx
@@ -43,8 +43,13 @@ export function SignInForm() {
setUserFromApi(response.data.user)
// Persist for refresh
localStorage.setItem("codenuk_user", JSON.stringify(response.data.user))
- // Go to main app
- router.push("/")
+
+ // Redirect based on user role
+ if (response.data.user.role === 'admin') {
+ router.push("/admin")
+ } else {
+ router.push("/")
+ }
} else {
setError("Invalid response from server. Please try again.")
}
diff --git a/src/components/features/feature-submission-form.tsx b/src/components/features/feature-submission-form.tsx
new file mode 100644
index 0000000..501b2b7
--- /dev/null
+++ b/src/components/features/feature-submission-form.tsx
@@ -0,0 +1,339 @@
+"use client"
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Label } from '@/components/ui/label'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+ CheckCircle,
+ AlertTriangle,
+ Loader2,
+ Search,
+ Info
+} from 'lucide-react'
+import { featureApi } from '@/lib/api/admin'
+import { FeatureSimilarity, DuplicateCheckResult } from '@/types/admin.types'
+
+interface FeatureSubmissionFormProps {
+ templateId: string
+ templateName?: string
+ onSuccess?: (feature: any) => void
+ onCancel?: () => void
+}
+
+export function FeatureSubmissionForm({
+ templateId,
+ templateName,
+ onSuccess,
+ onCancel
+}: FeatureSubmissionFormProps) {
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ complexity: 'medium' as 'low' | 'medium' | 'high',
+ business_rules: '',
+ technical_requirements: ''
+ })
+
+ const [submitting, setSubmitting] = useState(false)
+ const [similarFeatures, setSimilarFeatures] = useState([])
+ const [duplicateInfo, setDuplicateInfo] = useState(null)
+ const [searchingSimilar, setSearchingSimilar] = useState(false)
+ const [success, setSuccess] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleInputChange = (field: string, value: string) => {
+ setFormData(prev => ({ ...prev, [field]: value }))
+
+ // Clear previous results when name changes
+ if (field === 'name') {
+ setSimilarFeatures([])
+ setDuplicateInfo(null)
+ }
+ }
+
+ const searchSimilarFeatures = async () => {
+ if (!formData.name.trim()) return
+
+ try {
+ setSearchingSimilar(true)
+ const features = await featureApi.findSimilarFeatures(formData.name, 0.7, 5)
+ setSimilarFeatures(features)
+
+ // Check if any are potential duplicates
+ const potentialDuplicate = features.find(f => f.score >= 0.8)
+ if (potentialDuplicate) {
+ setDuplicateInfo({
+ isDuplicate: true,
+ canonicalFeature: potentialDuplicate,
+ similarityScore: potentialDuplicate.score,
+ matchType: potentialDuplicate.match_type
+ })
+ } else {
+ setDuplicateInfo({ isDuplicate: false })
+ }
+ } catch (error) {
+ console.error('Error searching similar features:', error)
+ } finally {
+ setSearchingSimilar(false)
+ }
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!formData.name.trim()) {
+ setError('Feature name is required')
+ return
+ }
+
+ try {
+ setSubmitting(true)
+ setError(null)
+
+ const response = await featureApi.submitCustomFeature({
+ template_id: templateId,
+ name: formData.name.trim(),
+ description: formData.description.trim() || undefined,
+ complexity: formData.complexity,
+ business_rules: formData.business_rules.trim() ? JSON.parse(formData.business_rules) : undefined,
+ technical_requirements: formData.technical_requirements.trim() ? JSON.parse(formData.technical_requirements) : undefined,
+ created_by_user_session: 'user-session' // TODO: Get from auth context
+ })
+
+ setSuccess(true)
+ if (onSuccess) {
+ onSuccess(response.data)
+ }
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Failed to submit feature')
+ console.error('Error submitting feature:', error)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const getComplexityColor = (complexity: string) => {
+ switch (complexity) {
+ case 'low':
+ return 'bg-green-100 text-green-800'
+ case 'medium':
+ return 'bg-yellow-100 text-yellow-800'
+ case 'high':
+ return 'bg-red-100 text-red-800'
+ default:
+ return 'bg-gray-100 text-gray-800'
+ }
+ }
+
+ if (success) {
+ return (
+
+
+
+
+
Feature Submitted Successfully!
+
+ Your feature "{formData.name}" has been submitted and is pending admin review.
+
+ {duplicateInfo?.isDuplicate && (
+
+
+
+ Similar features were found. An admin will review this submission to determine if it's a duplicate.
+
+
+ )}
+
+ window.location.reload()}>
+ Submit Another Feature
+
+ {onCancel && (
+
+ Close
+
+ )}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ Submit Custom Feature
+ {templateName && (
+
+ Adding feature to template: {templateName}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx
index 644ed53..8a59001 100644
--- a/src/components/layout/app-layout.tsx
+++ b/src/components/layout/app-layout.tsx
@@ -1,7 +1,7 @@
"use client"
import { useAuth } from "@/contexts/auth-context"
-import Header from "@/components/navigation/header"
+import { Header } from "@/components/navigation/header"
import { usePathname } from "next/navigation"
interface AppLayoutProps {
@@ -9,37 +9,18 @@ interface AppLayoutProps {
}
export function AppLayout({ children }: AppLayoutProps) {
- const { isAuthenticated, isLoading } = useAuth()
+ const { user } = useAuth()
const pathname = usePathname()
// Don't show header on auth pages
- const isAuthPage = pathname === "/auth" || pathname === "/signin" || pathname === "/signup"
-
- // Show loading state while checking auth
- if (isLoading) {
- return (
-
- )
- }
+ const isAuthPage = pathname === "/signin" || pathname === "/signup"
// For auth pages, don't show header
if (isAuthPage) {
return <>{children}>
}
- // For authenticated users on other pages, show header
- if (isAuthenticated) {
- return (
- <>
-
- {children}
- >
- )
- }
-
- // For unauthenticated users on non-auth pages, show header but redirect to auth
+ // For all other pages, show header
return (
<>
diff --git a/src/components/navigation/header.tsx b/src/components/navigation/header.tsx
index f9d28a2..d01c619 100644
--- a/src/components/navigation/header.tsx
+++ b/src/components/navigation/header.tsx
@@ -14,22 +14,22 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
-import { Bell, Settings, LogOut, User, Menu, X } from "lucide-react";
+import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
import { useAuth } from "@/contexts/auth-context";
const navigation = [
- { name: "Project Builder", href: "/", current: false },
- { name: "Templates", href: "/templates", current: false },
- { name: "Features", href: "/features", current: false },
- { name: "Business Context", href: "/business-context", current: false },
- { name: "Architecture", href: "/architecture", current: false },
+ { name: "Project Builder", href: "/project-builder" },
+ { name: "Templates", href: "/templates" },
+ { name: "Features", href: "/features" },
+ { name: "Business Context", href: "/business-context" },
+ { name: "Architecture", href: "/architecture" },
];
-export default function Header() {
+export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const pathname = usePathname();
- const { user, logout, isLoading } = useAuth();
+ const { user, logout, isAdmin } = useAuth();
// Handle logout with loading state
const handleLogout = async () => {
@@ -77,12 +77,26 @@ export default function Header() {
{item.name}
))}
+ {/* Admin Navigation */}
+ {isAdmin && (
+
+
+ Admin
+
+ )}
{/* Right side */}
{/* While loading, don't show sign-in or user menu to avoid flicker */}
- {!isLoading && (!user ? (
+ {!user ? (
Sign In
@@ -96,10 +110,10 @@ export default function Header() {
>
- ))}
+ )}
{/* User Menu - Only show when user is authenticated */}
- {!isLoading && user && user.email && (
+ {user && user.email && (
@@ -116,6 +130,12 @@ export default function Header() {
{user.name || user.email || "User"}
{user.email || "No email"}
+ {isAdmin && (
+
+
+ Admin
+
+ )}
@@ -130,14 +150,19 @@ export default function Header() {
- {isLoggingOut ? 'Logging out...' : 'Log out'}
+ {isLoggingOut ? "Logging out..." : "Log out"}
)}
{/* Mobile menu button */}
-
setMobileMenuOpen(!mobileMenuOpen)}>
+ setMobileMenuOpen(!mobileMenuOpen)}
+ >
{mobileMenuOpen ? : }
@@ -145,13 +170,13 @@ export default function Header() {
{/* Mobile Navigation */}
{mobileMenuOpen && (
-
-
+
+
{navigation.map((item) => (
))}
-
+ {/* Admin Navigation for mobile */}
+ {isAdmin && (
+
setMobileMenuOpen(false)}
+ >
+
+
Admin
+
+ )}
+
)}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..d9ccec9
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..84649ad
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left"
+}) {
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/src/contexts/auth-context.tsx b/src/contexts/auth-context.tsx
index d9649db..4f7018b 100644
--- a/src/contexts/auth-context.tsx
+++ b/src/contexts/auth-context.tsx
@@ -1,172 +1,74 @@
"use client"
-import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"
-import { logout as apiLogout } from "@/components/apis/authApiClients"
-import { safeLocalStorage } from "@/lib/utils"
+import React, { createContext, useContext, useEffect, useState } from 'react'
+import { safeLocalStorage } from '@/lib/utils'
+import { logout as logoutApi } from '@/components/apis/authApiClients'
interface User {
id: string
- name: string
email: string
- avatar?: string
+ username: string
+ role?: 'user' | 'admin'
}
-interface AuthContextType {
+interface AuthContextValue {
user: User | null
- isAuthenticated: boolean
- isLoading: boolean
- login: (email: string, password: string) => Promise
- signup: (name: string, email: string, password: string) => Promise
+ isAdmin: boolean
+ setUserFromApi: (user: User) => void
logout: () => Promise
- setUserFromApi: (apiUser: any) => void
}
-const AuthContext = createContext(undefined)
+const AuthContext = createContext(undefined)
-export function useAuth() {
- const context = useContext(AuthContext)
- if (context === undefined) {
- throw new Error("useAuth must be used within an AuthProvider")
- }
- return context
-}
-
-interface AuthProviderProps {
- children: ReactNode
-}
-
-export function AuthProvider({ children }: AuthProviderProps) {
+export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null)
- const [isLoading, setIsLoading] = useState(true)
- // Check if user is logged in on mount
useEffect(() => {
- const checkAuth = () => {
- // Check localStorage for user data
- const userData = safeLocalStorage.getItem("codenuk_user")
- if (userData) {
- try {
- const user = JSON.parse(userData)
- // Ensure user object has all required properties with fallbacks
- const validatedUser: User = {
- id: user.id || "1",
- name: user.name || user.email?.split("@")[0] || "User",
- email: user.email || "user@example.com",
- avatar: user.avatar || "/avatars/01.png"
- }
- setUser(validatedUser)
- } catch (error) {
- console.error("Error parsing user data:", error)
- safeLocalStorage.removeItem("codenuk_user")
- }
+ const stored = safeLocalStorage.getItem('codenuk_user')
+ if (stored) {
+ try {
+ const userData = JSON.parse(stored)
+ setUser(userData)
+ } catch (error) {
+ console.error('Failed to parse stored user data:', error)
+ safeLocalStorage.removeItem('codenuk_user')
}
- setIsLoading(false)
}
-
- checkAuth()
}, [])
- // Allow setting user right after successful API login
- const setUserFromApi = (apiUser: any) => {
- const validatedUser: User = {
- id: (apiUser.id || apiUser.user_id || "1").toString(),
- name:
- apiUser.name ||
- [apiUser.first_name, apiUser.last_name].filter(Boolean).join(" ") ||
- apiUser.username ||
- (apiUser.email ? apiUser.email.split("@")[0] : "User"),
- email: apiUser.email || "user@example.com",
- avatar: apiUser.avatar || "/avatars/01.png",
- }
- setUser(validatedUser)
- safeLocalStorage.setItem("codenuk_user", JSON.stringify(validatedUser))
+ const setUserFromApi = (u: User) => {
+ setUser(u)
+ safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
}
- const login = async (email: string, password: string): Promise => {
+ const logout = async () => {
try {
- setIsLoading(true)
-
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 1000))
-
- // For demo purposes, accept any email/password combination
- if (email && password) {
- const user: User = {
- id: "1",
- name: email.split("@")[0], // Use email prefix as name
- email: email,
- avatar: "/avatars/01.png"
- }
-
- setUser(user)
- safeLocalStorage.setItem("codenuk_user", JSON.stringify(user))
- return true
- }
-
- return false
+ // Call the logout API to invalidate tokens on the server
+ await logoutApi()
} catch (error) {
- console.error("Login error:", error)
- return false
+ console.error('Logout API call failed:', error)
+ // Continue with local logout even if API call fails
} finally {
- setIsLoading(false)
- }
- }
-
- const signup = async (name: string, email: string, password: string): Promise => {
- try {
- setIsLoading(true)
+ // Always clear local data
+ setUser(null)
+ safeLocalStorage.removeItem('codenuk_user')
+ safeLocalStorage.removeItem('accessToken')
+ safeLocalStorage.removeItem('refreshToken')
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 1000))
-
- // For demo purposes, create user if all fields are provided
- if (name && email && password) {
- const user: User = {
- id: "1",
- name: name,
- email: email,
- avatar: "/avatars/01.png"
- }
-
- setUser(user)
- safeLocalStorage.setItem("codenuk_user", JSON.stringify(user))
- return true
- }
-
- return false
- } catch (error) {
- console.error("Signup error:", error)
- return false
- } finally {
- setIsLoading(false)
+ // Redirect to signin page
+ window.location.href = '/signin'
}
}
- const logout = async (): Promise => {
- try {
- // Call the API logout function which handles backend call, token clearing, and redirect
- await apiLogout();
- } catch (error) {
- console.error("Logout error:", error);
- // Even if there's an error, clear local state
- setUser(null);
- safeLocalStorage.removeItem("codenuk_user");
- }
- }
-
- const value: AuthContextType = {
- user,
- isAuthenticated: !!user,
- isLoading,
- login,
- signup,
- logout,
- setUserFromApi
- }
-
return (
-
+
{children}
)
}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider')
+ return ctx
+}
diff --git a/src/lib/api/admin.ts b/src/lib/api/admin.ts
new file mode 100644
index 0000000..248da0e
--- /dev/null
+++ b/src/lib/api/admin.ts
@@ -0,0 +1,203 @@
+import {
+ AdminFeature,
+ AdminNotification,
+ FeatureSimilarity,
+ FeatureReviewData,
+ AdminStats,
+ FeatureSynonym,
+ AdminApiResponse
+} from '@/types/admin.types';
+import { getAccessToken } from '@/components/apis/authApiClients';
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_TEMPLATE_MANAGER_URL || 'http://localhost:8009';
+
+// Helper function to make API calls
+async function apiCall(
+ endpoint: string,
+ options: RequestInit = {}
+): Promise> {
+ const url = `${API_BASE_URL}${endpoint}`;
+
+ const token = getAccessToken();
+ const defaultHeaders: Record = {
+ 'Content-Type': 'application/json',
+ };
+ if (token) {
+ defaultHeaders['Authorization'] = `Bearer ${token}`;
+ }
+
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ ...defaultHeaders,
+ ...options.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+// Admin Feature Management API
+export const adminApi = {
+ // Get pending features
+ getPendingFeatures: async (limit = 50, offset = 0): Promise => {
+ const response = await apiCall(`/api/admin/features/pending?limit=${limit}&offset=${offset}`);
+ return response.data;
+ },
+
+ // Get features by status
+ getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise => {
+ const response = await apiCall(`/api/admin/features/status/${status}?limit=${limit}&offset=${offset}`);
+ return response.data;
+ },
+
+ // Get feature statistics
+ getFeatureStats: async (): Promise => {
+ const response = await apiCall('/api/admin/features/stats');
+ return response.data;
+ },
+
+ // Review a feature
+ reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise => {
+ const response = await apiCall(`/api/admin/features/${featureId}/review`, {
+ method: 'POST',
+ body: JSON.stringify(reviewData),
+ });
+ return response.data;
+ },
+
+ // Find similar features
+ findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise => {
+ const response = await apiCall(
+ `/api/admin/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
+ );
+ return response.data;
+ },
+
+ // Add feature synonym
+ addFeatureSynonym: async (featureId: string, synonym: string, createdBy?: string): Promise => {
+ const response = await apiCall(`/api/admin/features/${featureId}/synonyms`, {
+ method: 'POST',
+ body: JSON.stringify({ synonym, created_by: createdBy }),
+ });
+ return response.data;
+ },
+
+ // Get feature synonyms
+ getFeatureSynonyms: async (featureId: string): Promise => {
+ const response = await apiCall(`/api/admin/features/${featureId}/synonyms`);
+ return response.data;
+ },
+
+ // Get admin notifications
+ getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => {
+ const response = await apiCall(
+ `/api/admin/notifications?unread_only=${unreadOnly}&limit=${limit}&offset=${offset}`
+ );
+ return response.data;
+ },
+
+ // Mark notification as read
+ markNotificationAsRead: async (notificationId: string): Promise => {
+ const response = await apiCall(`/api/admin/notifications/${notificationId}/read`, {
+ method: 'POST',
+ });
+ return response.data;
+ },
+
+ // Mark all notifications as read
+ markAllNotificationsAsRead: async (): Promise<{ count: number }> => {
+ const response = await apiCall<{ count: number }>('/api/admin/notifications/read-all', {
+ method: 'POST',
+ });
+ return response.data;
+ },
+};
+
+// Feature submission API (for regular users)
+export const featureApi = {
+ // Submit a new custom feature
+ submitCustomFeature: async (featureData: {
+ template_id: string;
+ name: string;
+ description?: string;
+ complexity: 'low' | 'medium' | 'high';
+ business_rules?: any;
+ technical_requirements?: any;
+ created_by_user_session?: string;
+ }): Promise<{ data: AdminFeature; similarityInfo?: any }> => {
+ const response = await apiCall('/api/features/custom', {
+ method: 'POST',
+ body: JSON.stringify(featureData),
+ });
+
+ return {
+ data: response.data,
+ similarityInfo: (response as any).similarityInfo,
+ };
+ },
+
+ // Find similar features (for users)
+ findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise => {
+ const response = await apiCall(
+ `/api/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
+ );
+ return response.data;
+ },
+};
+
+// Error handling utilities
+export class AdminApiError extends Error {
+ constructor(
+ message: string,
+ public status?: number,
+ public code?: string
+ ) {
+ super(message);
+ this.name = 'AdminApiError';
+ }
+}
+
+// Utility functions
+export const formatDate = (dateString: string): string => {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+};
+
+export const getStatusColor = (status: string): string => {
+ switch (status) {
+ case 'pending':
+ return 'bg-yellow-100 text-yellow-800';
+ case 'approved':
+ return 'bg-green-100 text-green-800';
+ case 'rejected':
+ return 'bg-red-100 text-red-800';
+ case 'duplicate':
+ return 'bg-orange-100 text-orange-800';
+ default:
+ return 'bg-gray-100 text-gray-800';
+ }
+};
+
+export const getComplexityColor = (complexity: string): string => {
+ switch (complexity) {
+ case 'low':
+ return 'bg-green-100 text-green-800';
+ case 'medium':
+ return 'bg-yellow-100 text-yellow-800';
+ case 'high':
+ return 'bg-red-100 text-red-800';
+ default:
+ return 'bg-gray-100 text-gray-800';
+ }
+};
diff --git a/src/types/admin.types.ts b/src/types/admin.types.ts
new file mode 100644
index 0000000..1089de4
--- /dev/null
+++ b/src/types/admin.types.ts
@@ -0,0 +1,121 @@
+// Admin approval workflow types
+
+export interface AdminFeature {
+ id: string;
+ template_id: string;
+ name: string;
+ description?: string;
+ complexity: 'low' | 'medium' | 'high';
+ business_rules?: any;
+ technical_requirements?: any;
+ approved: boolean;
+ usage_count: number;
+ created_by_user_session?: string;
+ created_at: string;
+ updated_at: string;
+ // Admin workflow fields
+ status: 'pending' | 'approved' | 'rejected' | 'duplicate';
+ admin_notes?: string;
+ admin_reviewed_at?: string;
+ admin_reviewed_by?: string;
+ canonical_feature_id?: string;
+ similarity_score?: number;
+ // Additional fields from joins
+ template_title?: string;
+}
+
+export interface AdminNotification {
+ id: string;
+ type: string;
+ message: string;
+ reference_id?: string;
+ reference_type?: string;
+ is_read: boolean;
+ created_at: string;
+ read_at?: string;
+}
+
+export interface FeatureSimilarity {
+ id: string;
+ name: string;
+ match_type: 'exact' | 'synonym' | 'fuzzy';
+ score: number;
+ feature_type: string;
+ complexity: string;
+}
+
+export interface DuplicateCheckResult {
+ isDuplicate: boolean;
+ canonicalFeature?: FeatureSimilarity;
+ similarityScore?: number;
+ matchType?: string;
+}
+
+export interface FeatureReviewData {
+ status: 'approved' | 'rejected' | 'duplicate';
+ notes?: string;
+ canonical_feature_id?: string;
+ admin_reviewed_by: string;
+}
+
+export interface FeatureStats {
+ status: string;
+ count: number;
+}
+
+export interface NotificationCounts {
+ total: number;
+ unread: number;
+ read: number;
+}
+
+export interface AdminStats {
+ features: FeatureStats[];
+ notifications: NotificationCounts;
+}
+
+export interface FeatureSynonym {
+ id: string;
+ feature_id: string;
+ synonym: string;
+ created_by?: string;
+ created_at: string;
+}
+
+// API Response types
+export interface AdminApiResponse {
+ success: boolean;
+ data: T;
+ count?: number;
+ message: string;
+ error?: string;
+}
+
+export interface FeatureSubmissionResponse {
+ success: boolean;
+ data: AdminFeature;
+ message: string;
+ similarityInfo?: DuplicateCheckResult;
+}
+
+// Admin dashboard state
+export interface AdminDashboardState {
+ pendingFeatures: AdminFeature[];
+ notifications: AdminNotification[];
+ stats: AdminStats | null;
+ loading: boolean;
+ error: string | null;
+}
+
+// Admin filters and pagination
+export interface AdminFilters {
+ status?: 'pending' | 'approved' | 'rejected' | 'duplicate';
+ search?: string;
+ complexity?: 'low' | 'medium' | 'high';
+}
+
+export interface AdminPagination {
+ page: number;
+ limit: number;
+ total: number;
+}