Updated codebase

This commit is contained in:
tejas.prakash 2025-08-25 08:17:46 +05:30
parent 1022b3df3a
commit ff99b4df53
17 changed files with 2182 additions and 184 deletions

109
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@next/font": "^14.2.15", "@next/font": "^14.2.15",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@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-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",

View File

@ -13,6 +13,7 @@
"@next/font": "^14.2.15", "@next/font": "^14.2.15",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@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-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",

59
src/app/admin/page.tsx Normal file
View File

@ -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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
<p className="text-white/60">Loading...</p>
</div>
</div>
)
}
// Show access denied for non-admin users
if (!isAdmin) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4">🚫</div>
<h1 className="text-2xl font-bold text-white mb-2">Access Denied</h1>
<p className="text-white/60 mb-4">You don't have permission to access this page.</p>
<button
onClick={() => router.push('/')}
className="px-4 py-2 bg-orange-500 text-black rounded-md hover:bg-orange-600 transition-colors"
>
Go to Home
</button>
</div>
</div>
)
}
return (
<div className="container mx-auto px-4 py-8">
<AdminDashboard />
</div>
)
}

View File

@ -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<AdminFeature[]>([])
const [notifications, setNotifications] = useState<AdminNotification[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
const [showReviewDialog, setShowReviewDialog] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('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 (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center space-x-2">
<RefreshCw className="h-6 w-6 animate-spin" />
<span>Loading admin dashboard...</span>
</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="flex items-center space-x-2 text-red-600">
<AlertCircle className="h-6 w-6" />
<span>Error loading dashboard</span>
</div>
<p className="mt-2 text-sm text-gray-600">{error}</p>
<Button onClick={loadDashboardData} className="mt-4">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<p className="text-gray-600">Manage feature approvals and system notifications</p>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
onClick={() => setShowNotifications(true)}
className="relative"
>
<Bell className="h-4 w-4 mr-2" />
Notifications
{getUnreadNotificationCount() > 0 && (
<Badge className="ml-2 bg-red-500 text-white">
{getUnreadNotificationCount()}
</Badge>
)}
</Button>
<Button onClick={loadDashboardData}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-2">
<Clock className="h-5 w-5 text-yellow-600" />
<div>
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold">{getStatusCount('pending')}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<div>
<p className="text-sm font-medium text-gray-600">Approved</p>
<p className="text-2xl font-bold">{getStatusCount('approved')}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-2">
<XCircle className="h-5 w-5 text-red-600" />
<div>
<p className="text-sm font-medium text-gray-600">Rejected</p>
<p className="text-2xl font-bold">{getStatusCount('rejected')}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-2">
<Copy className="h-5 w-5 text-orange-600" />
<div>
<p className="text-sm font-medium text-gray-600">Duplicates</p>
<p className="text-2xl font-bold">{getStatusCount('duplicate')}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Content */}
<Tabs defaultValue="pending" className="space-y-4">
<TabsList>
<TabsTrigger value="pending" className="flex items-center space-x-2">
<Clock className="h-4 w-4" />
<span>Pending Review ({pendingFeatures.length})</span>
</TabsTrigger>
<TabsTrigger value="all" className="flex items-center space-x-2">
<Filter className="h-4 w-4" />
<span>All Features</span>
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="space-y-4">
{/* Filters */}
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search features..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="duplicate">Duplicate</SelectItem>
</SelectContent>
</Select>
</div>
{/* Features List */}
<div className="space-y-4">
{filteredFeatures.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center text-gray-500">
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No features found</p>
<p className="text-sm">All features have been reviewed or no features match your filters.</p>
</div>
</CardContent>
</Card>
) : (
filteredFeatures.map((feature) => (
<Card key={feature.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-lg font-semibold">{feature.name}</h3>
<Badge className={getStatusColor(feature.status)}>
{feature.status}
</Badge>
<Badge className={getComplexityColor(feature.complexity)}>
{feature.complexity}
</Badge>
</div>
{feature.description && (
<p className="text-gray-600 mb-2">{feature.description}</p>
)}
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span>Template: {feature.template_title || 'Unknown'}</span>
<span>Submitted: {formatDate(feature.created_at)}</span>
{feature.similarity_score && (
<span>Similarity: {(feature.similarity_score * 100).toFixed(1)}%</span>
)}
</div>
{feature.admin_notes && (
<div className="mt-2 p-2 bg-gray-50 rounded">
<p className="text-sm text-gray-600">
<strong>Admin Notes:</strong> {feature.admin_notes}
</p>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedFeature(feature)
setShowReviewDialog(true)
}}
>
Review
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
<TabsContent value="all" className="space-y-4">
<p className="text-gray-600">
View and manage all features across different statuses. Use the filters above to narrow down results.
</p>
{/* TODO: Implement all features view with pagination */}
</TabsContent>
</Tabs>
{/* Review Dialog */}
{selectedFeature && (
<FeatureReviewDialog
feature={selectedFeature}
open={showReviewDialog}
onOpenChange={setShowReviewDialog}
onReview={handleFeatureReview}
/>
)}
{/* Notifications Panel */}
<AdminNotificationsPanel
open={showNotifications}
onOpenChange={setShowNotifications}
notifications={notifications}
onNotificationRead={async (id) => {
try {
await adminApi.markNotificationAsRead(id)
setNotifications(prev => prev.filter(n => n.id !== id))
} catch (err) {
console.error('Error marking notification as read:', err)
}
}}
/>
</div>
)
}

View File

@ -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<void>
}
export function AdminNotificationsPanel({
open,
onOpenChange,
notifications,
onNotificationRead
}: AdminNotificationsPanelProps) {
const [markingAsRead, setMarkingAsRead] = useState<string | null>(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 <Clock className="h-5 w-5 text-blue-600" />
case 'feature_reviewed':
return <CheckCircle className="h-5 w-5 text-green-600" />
default:
return <Bell className="h-5 w-5 text-gray-600" />
}
}
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-96 sm:w-[540px] overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center space-x-2">
<Bell className="h-5 w-5" />
<span>Admin Notifications</span>
{unreadNotifications.length > 0 && (
<Badge className="bg-red-500 text-white">
{unreadNotifications.length}
</Badge>
)}
</SheetTitle>
<SheetDescription>
System notifications and feature review updates
</SheetDescription>
</SheetHeader>
<div className="space-y-4 mt-6">
{/* Unread Notifications */}
{unreadNotifications.length > 0 && (
<div>
<h3 className="font-medium text-sm text-gray-600 mb-2">
Unread ({unreadNotifications.length})
</h3>
<div className="space-y-2">
{unreadNotifications.map((notification) => (
<Card
key={notification.id}
className={`border-l-4 ${getNotificationColor(notification.type)}`}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{getNotificationIcon(notification.type)}
<div className="flex-1">
<p className="text-sm font-medium">{notification.message}</p>
<p className="text-xs text-gray-500 mt-1">
{formatDate(notification.created_at)}
</p>
{notification.reference_type && (
<Badge variant="outline" className="mt-1 text-xs">
{notification.reference_type}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleMarkAsRead(notification.id)}
disabled={markingAsRead === notification.id}
className="ml-2"
>
{markingAsRead === notification.id ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
) : (
<Check className="h-4 w-4" />
)}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Read Notifications */}
{readNotifications.length > 0 && (
<div>
<h3 className="font-medium text-sm text-gray-600 mb-2">
Read ({readNotifications.length})
</h3>
<div className="space-y-2">
{readNotifications.map((notification) => (
<Card
key={notification.id}
className={`border-l-4 ${getNotificationColor(notification.type)} opacity-75`}
>
<CardContent className="pt-4">
<div className="flex items-start space-x-3">
{getNotificationIcon(notification.type)}
<div className="flex-1">
<p className="text-sm">{notification.message}</p>
<p className="text-xs text-gray-500 mt-1">
{formatDate(notification.created_at)}
{notification.read_at && (
<span className="ml-2">
Read {formatDate(notification.read_at)}
</span>
)}
</p>
{notification.reference_type && (
<Badge variant="outline" className="mt-1 text-xs">
{notification.reference_type}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Empty State */}
{notifications.length === 0 && (
<div className="text-center py-8">
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500">No notifications</p>
<p className="text-sm text-gray-400 mt-1">
You're all caught up! New notifications will appear here.
</p>
</div>
)}
</div>
{/* Footer Actions */}
{notifications.length > 0 && (
<div className="mt-6 pt-4 border-t">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{unreadNotifications.length} unread, {readNotifications.length} read
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
// TODO: Implement mark all as read
console.log('Mark all as read')
}}
>
<Check className="h-4 w-4 mr-2" />
Mark All Read
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}

View File

@ -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<void>
}
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<FeatureSimilarity[]>([])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review Feature: {feature.name}</DialogTitle>
<DialogDescription>
Review and approve, reject, or mark as duplicate this custom feature submission.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Feature Details */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold">{feature.name}</h3>
<Badge className={getStatusColor(feature.status)}>
{feature.status}
</Badge>
<Badge className={getComplexityColor(feature.complexity)}>
{feature.complexity}
</Badge>
</div>
{feature.description && (
<p className="text-gray-600">{feature.description}</p>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Template:</span> {feature.template_title || 'Unknown'}
</div>
<div>
<span className="font-medium">Submitted:</span> {formatDate(feature.created_at)}
</div>
{feature.similarity_score && (
<div>
<span className="font-medium">Similarity Score:</span> {(feature.similarity_score * 100).toFixed(1)}%
</div>
)}
<div>
<span className="font-medium">Usage Count:</span> {feature.usage_count}
</div>
</div>
{feature.business_rules && (
<div>
<h4 className="font-medium mb-2">Business Rules:</h4>
<pre className="text-sm bg-gray-50 p-2 rounded">
{JSON.stringify(feature.business_rules, null, 2)}
</pre>
</div>
)}
{feature.technical_requirements && (
<div>
<h4 className="font-medium mb-2">Technical Requirements:</h4>
<pre className="text-sm bg-gray-50 p-2 rounded">
{JSON.stringify(feature.technical_requirements, null, 2)}
</pre>
</div>
)}
</div>
</CardContent>
</Card>
{/* Similar Features */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium">Similar Features</h4>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search similar features..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2 border rounded-md text-sm"
/>
</div>
</div>
{loadingSimilar ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading similar features...</p>
</div>
) : filteredSimilarFeatures.length > 0 ? (
<div className="space-y-2">
{filteredSimilarFeatures.map((similar) => (
<div
key={similar.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center space-x-2">
<span className="font-medium">{similar.name}</span>
<Badge className={getComplexityColor(similar.complexity)}>
{similar.complexity}
</Badge>
<Badge variant="outline">
{similar.match_type} ({(similar.score * 100).toFixed(1)}%)
</Badge>
</div>
<p className="text-sm text-gray-500">{similar.feature_type}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCanonicalFeatureId(similar.id)}
disabled={status !== 'duplicate'}
>
<ExternalLink className="h-4 w-4 mr-1" />
Select as Duplicate
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-4 text-gray-500">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p>No similar features found</p>
</div>
)}
</CardContent>
</Card>
{/* Review Form */}
<div className="space-y-4">
<div>
<Label htmlFor="status">Review Decision</Label>
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="Select review decision" />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">
<div className="flex items-center space-x-2">
<CheckCircle className="h-4 w-4 text-green-600" />
<span>Approve</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center space-x-2">
<XCircle className="h-4 w-4 text-red-600" />
<span>Reject</span>
</div>
</SelectItem>
<SelectItem value="duplicate">
<div className="flex items-center space-x-2">
<Copy className="h-4 w-4 text-orange-600" />
<span>Mark as Duplicate</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{status === 'duplicate' && (
<div>
<Label htmlFor="canonical">Canonical Feature ID</Label>
<input
id="canonical"
type="text"
value={canonicalFeatureId}
onChange={(e) => setCanonicalFeatureId(e.target.value)}
placeholder="Enter the ID of the canonical feature"
className="w-full px-3 py-2 border rounded-md"
/>
<p className="text-sm text-gray-500 mt-1">
Select a similar feature above or enter the canonical feature ID manually
</p>
</div>
)}
<div>
<Label htmlFor="notes">Admin Notes (Optional)</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes about your decision..."
rows={3}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || (status === 'duplicate' && !canonicalFeatureId)}
>
{submitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Submitting...
</>
) : (
`Submit ${status.charAt(0).toUpperCase() + status.slice(1)}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -34,12 +34,10 @@ export const logout = async () => {
console.error('Logout API call failed:', error); console.error('Logout API call failed:', error);
// Continue with logout even if API call fails // Continue with logout even if API call fails
} finally { } finally {
// Always clear tokens and redirect // Always clear tokens
clearTokens(); clearTokens();
// Clear any other user data // Clear any other user data
safeLocalStorage.removeItem('codenuk_user'); safeLocalStorage.removeItem('codenuk_user');
// Redirect to signin page
safeRedirect('/signin');
} }
}; };
@ -83,7 +81,8 @@ const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
} catch (refreshError) { } catch (refreshError) {
console.error('Token refresh failed:', refreshError); console.error('Token refresh failed:', refreshError);
clearTokens(); clearTokens();
window.location.href = '/auth'; safeLocalStorage.removeItem('codenuk_user');
window.location.href = '/signin';
return Promise.reject(refreshError); return Promise.reject(refreshError);
} }
} }

View File

@ -43,8 +43,13 @@ export function SignInForm() {
setUserFromApi(response.data.user) setUserFromApi(response.data.user)
// Persist for refresh // Persist for refresh
localStorage.setItem("codenuk_user", JSON.stringify(response.data.user)) 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 { } else {
setError("Invalid response from server. Please try again.") setError("Invalid response from server. Please try again.")
} }

View File

@ -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<FeatureSimilarity[]>([])
const [duplicateInfo, setDuplicateInfo] = useState<DuplicateCheckResult | null>(null)
const [searchingSimilar, setSearchingSimilar] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Card>
<CardContent className="pt-6">
<div className="text-center">
<CheckCircle className="h-12 w-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Feature Submitted Successfully!</h3>
<p className="text-gray-600 mb-4">
Your feature "{formData.name}" has been submitted and is pending admin review.
</p>
{duplicateInfo?.isDuplicate && (
<Alert className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Similar features were found. An admin will review this submission to determine if it's a duplicate.
</AlertDescription>
</Alert>
)}
<div className="space-x-2">
<Button onClick={() => window.location.reload()}>
Submit Another Feature
</Button>
{onCancel && (
<Button variant="outline" onClick={onCancel}>
Close
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Submit Custom Feature</CardTitle>
{templateName && (
<p className="text-sm text-gray-600">
Adding feature to template: <strong>{templateName}</strong>
</p>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Feature Name */}
<div className="space-y-2">
<Label htmlFor="name">Feature Name *</Label>
<div className="flex space-x-2">
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Enter feature name..."
required
/>
<Button
type="button"
variant="outline"
onClick={searchSimilarFeatures}
disabled={!formData.name.trim() || searchingSimilar}
>
{searchingSimilar ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Similar Features Alert */}
{similarFeatures.length > 0 && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<p>Similar features found:</p>
<div className="space-y-1">
{similarFeatures.map((feature) => (
<div key={feature.id} className="flex items-center justify-between text-sm">
<span>{feature.name}</span>
<Badge variant="outline">
{(feature.score * 100).toFixed(1)}% match
</Badge>
</div>
))}
</div>
{duplicateInfo?.isDuplicate && (
<p className="text-amber-600 font-medium mt-2">
This may be a duplicate. Please review before submitting.
</p>
)}
</div>
</AlertDescription>
</Alert>
)}
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Describe what this feature does..."
rows={3}
/>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="complexity">Complexity</Label>
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">
<div className="flex items-center space-x-2">
<Badge className="bg-green-100 text-green-800">Low</Badge>
<span>Simple implementation</span>
</div>
</SelectItem>
<SelectItem value="medium">
<div className="flex items-center space-x-2">
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
<span>Moderate complexity</span>
</div>
</SelectItem>
<SelectItem value="high">
<div className="flex items-center space-x-2">
<Badge className="bg-red-100 text-red-800">High</Badge>
<span>Complex implementation</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Business Rules */}
<div className="space-y-2">
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
<Textarea
id="business_rules"
value={formData.business_rules}
onChange={(e) => handleInputChange('business_rules', e.target.value)}
placeholder='{"rule1": "description", "rule2": "description"}'
rows={3}
/>
<p className="text-xs text-gray-500">
Optional: Define business rules for this feature in JSON format
</p>
</div>
{/* Technical Requirements */}
<div className="space-y-2">
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
<Textarea
id="technical_requirements"
value={formData.technical_requirements}
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
placeholder='{"framework": "React", "database": "PostgreSQL"}'
rows={3}
/>
<p className="text-xs text-gray-500">
Optional: Define technical requirements in JSON format
</p>
</div>
{/* Error Display */}
{error && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Submit Button */}
<div className="flex space-x-2">
<Button
type="submit"
disabled={submitting || !formData.name.trim()}
className="flex-1"
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Submitting...
</>
) : (
'Submit Feature'
)}
</Button>
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
</div>
</form>
</CardContent>
</Card>
)
}

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import Header from "@/components/navigation/header" import { Header } from "@/components/navigation/header"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
interface AppLayoutProps { interface AppLayoutProps {
@ -9,37 +9,18 @@ interface AppLayoutProps {
} }
export function AppLayout({ children }: AppLayoutProps) { export function AppLayout({ children }: AppLayoutProps) {
const { isAuthenticated, isLoading } = useAuth() const { user } = useAuth()
const pathname = usePathname() const pathname = usePathname()
// Don't show header on auth pages // Don't show header on auth pages
const isAuthPage = pathname === "/auth" || pathname === "/signin" || pathname === "/signup" const isAuthPage = pathname === "/signin" || pathname === "/signup"
// Show loading state while checking auth
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-orange-500"></div>
</div>
)
}
// For auth pages, don't show header // For auth pages, don't show header
if (isAuthPage) { if (isAuthPage) {
return <>{children}</> return <>{children}</>
} }
// For authenticated users on other pages, show header // For all other pages, show header
if (isAuthenticated) {
return (
<>
<Header />
{children}
</>
)
}
// For unauthenticated users on non-auth pages, show header but redirect to auth
return ( return (
<> <>
<Header /> <Header />

View File

@ -14,22 +14,22 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge"; 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"; import { useAuth } from "@/contexts/auth-context";
const navigation = [ const navigation = [
{ name: "Project Builder", href: "/", current: false }, { name: "Project Builder", href: "/project-builder" },
{ name: "Templates", href: "/templates", current: false }, { name: "Templates", href: "/templates" },
{ name: "Features", href: "/features", current: false }, { name: "Features", href: "/features" },
{ name: "Business Context", href: "/business-context", current: false }, { name: "Business Context", href: "/business-context" },
{ name: "Architecture", href: "/architecture", current: false }, { name: "Architecture", href: "/architecture" },
]; ];
export default function Header() { export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const { user, logout, isLoading } = useAuth(); const { user, logout, isAdmin } = useAuth();
// Handle logout with loading state // Handle logout with loading state
const handleLogout = async () => { const handleLogout = async () => {
@ -77,12 +77,26 @@ export default function Header() {
{item.name} {item.name}
</Link> </Link>
))} ))}
{/* Admin Navigation */}
{isAdmin && (
<Link
href="/admin"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center space-x-1 ${
pathname === "/admin"
? "bg-orange-500 text-black"
: "text-white/70 hover:text-white hover:bg-white/5"
}`}
>
<Shield className="h-4 w-4" />
<span>Admin</span>
</Link>
)}
</nav> </nav>
{/* Right side */} {/* Right side */}
<div className="flex items-center space-x-4 cursor-pointer"> <div className="flex items-center space-x-4 cursor-pointer">
{/* While loading, don't show sign-in or user menu to avoid flicker */} {/* While loading, don't show sign-in or user menu to avoid flicker */}
{!isLoading && (!user ? ( {!user ? (
<Link href="/signin"> <Link href="/signin">
<Button size="sm" className="cursor-pointer">Sign In</Button> <Button size="sm" className="cursor-pointer">Sign In</Button>
</Link> </Link>
@ -96,10 +110,10 @@ export default function Header() {
</Badge> </Badge>
</Button> </Button>
</> </>
))} )}
{/* User Menu - Only show when user is authenticated */} {/* User Menu - Only show when user is authenticated */}
{!isLoading && user && user.email && ( {user && user.email && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5"> <Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
@ -116,6 +130,12 @@ export default function Header() {
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name || user.email || "User"}</p> <p className="text-sm font-medium leading-none">{user.name || user.email || "User"}</p>
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p> <p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
{isAdmin && (
<Badge className="w-fit bg-orange-500 text-black text-xs">
<Shield className="h-3 w-3 mr-1" />
Admin
</Badge>
)}
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -130,14 +150,19 @@ export default function Header() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}> <DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span>{isLoggingOut ? 'Logging out...' : 'Log out'}</span> <span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{/* Mobile menu button */} {/* Mobile menu button */}
<Button variant="ghost" size="sm" className="md:hidden text-white/80 hover:text-white hover:bg-white/5" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}> <Button
variant="ghost"
size="sm"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button> </Button>
</div> </div>
@ -145,13 +170,13 @@ export default function Header() {
{/* Mobile Navigation */} {/* Mobile Navigation */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="md:hidden"> <div className="md:hidden py-4 border-t border-white/10">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-white/10"> <nav className="flex flex-col space-y-2">
{navigation.map((item) => ( {navigation.map((item) => (
<Link <Link
key={item.name} key={item.name}
href={item.href} href={item.href}
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${ className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
pathname === item.href pathname === item.href
? "bg-orange-500 text-black" ? "bg-orange-500 text-black"
: "text-white/70 hover:text-white hover:bg-white/5" : "text-white/70 hover:text-white hover:bg-white/5"
@ -161,7 +186,22 @@ export default function Header() {
{item.name} {item.name}
</Link> </Link>
))} ))}
</div> {/* Admin Navigation for mobile */}
{isAdmin && (
<Link
href="/admin"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center space-x-1 ${
pathname === "/admin"
? "bg-orange-500 text-black"
: "text-white/70 hover:text-white hover:bg-white/5"
}`}
onClick={() => setMobileMenuOpen(false)}
>
<Shield className="h-4 w-4" />
<span>Admin</span>
</Link>
)}
</nav>
</div> </div>
)} )}
</div> </div>

View File

@ -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<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

139
src/components/ui/sheet.tsx Normal file
View File

@ -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<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -1,172 +1,74 @@
"use client" "use client"
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react" import React, { createContext, useContext, useEffect, useState } from 'react'
import { logout as apiLogout } from "@/components/apis/authApiClients" import { safeLocalStorage } from '@/lib/utils'
import { safeLocalStorage } from "@/lib/utils" import { logout as logoutApi } from '@/components/apis/authApiClients'
interface User { interface User {
id: string id: string
name: string
email: string email: string
avatar?: string username: string
role?: 'user' | 'admin'
} }
interface AuthContextType { interface AuthContextValue {
user: User | null user: User | null
isAuthenticated: boolean isAdmin: boolean
isLoading: boolean setUserFromApi: (user: User) => void
login: (email: string, password: string) => Promise<boolean>
signup: (name: string, email: string, password: string) => Promise<boolean>
logout: () => Promise<void> logout: () => Promise<void>
setUserFromApi: (apiUser: any) => void
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined) const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function useAuth() { export function AuthProvider({ children }: { children: React.ReactNode }) {
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) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Check if user is logged in on mount
useEffect(() => { useEffect(() => {
const checkAuth = () => { const stored = safeLocalStorage.getItem('codenuk_user')
// Check localStorage for user data if (stored) {
const userData = safeLocalStorage.getItem("codenuk_user") try {
if (userData) { const userData = JSON.parse(stored)
try { setUser(userData)
const user = JSON.parse(userData) } catch (error) {
// Ensure user object has all required properties with fallbacks console.error('Failed to parse stored user data:', error)
const validatedUser: User = { safeLocalStorage.removeItem('codenuk_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")
}
} }
setIsLoading(false)
} }
checkAuth()
}, []) }, [])
// Allow setting user right after successful API login const setUserFromApi = (u: User) => {
const setUserFromApi = (apiUser: any) => { setUser(u)
const validatedUser: User = { safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
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 login = async (email: string, password: string): Promise<boolean> => { const logout = async () => {
try { try {
setIsLoading(true) // Call the logout API to invalidate tokens on the server
await logoutApi()
// 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
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error('Logout API call failed:', error)
return false // Continue with local logout even if API call fails
} finally { } finally {
setIsLoading(false) // Always clear local data
} setUser(null)
} safeLocalStorage.removeItem('codenuk_user')
safeLocalStorage.removeItem('accessToken')
const signup = async (name: string, email: string, password: string): Promise<boolean> => { safeLocalStorage.removeItem('refreshToken')
try {
setIsLoading(true)
// Simulate API call // Redirect to signin page
await new Promise(resolve => setTimeout(resolve, 1000)) window.location.href = '/signin'
// 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)
} }
} }
const logout = async (): Promise<void> => {
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 ( return (
<AuthContext.Provider value={value}> <AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
} }
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

203
src/lib/api/admin.ts Normal file
View File

@ -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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<AdminApiResponse<T>> {
const url = `${API_BASE_URL}${endpoint}`;
const token = getAccessToken();
const defaultHeaders: Record<string, string> = {
'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<AdminFeature[]> => {
const response = await apiCall<AdminFeature[]>(`/api/admin/features/pending?limit=${limit}&offset=${offset}`);
return response.data;
},
// Get features by status
getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
const response = await apiCall<AdminFeature[]>(`/api/admin/features/status/${status}?limit=${limit}&offset=${offset}`);
return response.data;
},
// Get feature statistics
getFeatureStats: async (): Promise<AdminStats> => {
const response = await apiCall<AdminStats>('/api/admin/features/stats');
return response.data;
},
// Review a feature
reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/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<FeatureSimilarity[]> => {
const response = await apiCall<FeatureSimilarity[]>(
`/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<FeatureSynonym> => {
const response = await apiCall<FeatureSynonym>(`/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<FeatureSynonym[]> => {
const response = await apiCall<FeatureSynonym[]>(`/api/admin/features/${featureId}/synonyms`);
return response.data;
},
// Get admin notifications
getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<AdminNotification[]> => {
const response = await apiCall<AdminNotification[]>(
`/api/admin/notifications?unread_only=${unreadOnly}&limit=${limit}&offset=${offset}`
);
return response.data;
},
// Mark notification as read
markNotificationAsRead: async (notificationId: string): Promise<AdminNotification> => {
const response = await apiCall<AdminNotification>(`/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<AdminFeature>('/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<FeatureSimilarity[]> => {
const response = await apiCall<FeatureSimilarity[]>(
`/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';
}
};

121
src/types/admin.types.ts Normal file
View File

@ -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<T> {
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;
}