Updated codebase
This commit is contained in:
parent
1022b3df3a
commit
ff99b4df53
109
package-lock.json
generated
109
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
59
src/app/admin/page.tsx
Normal file
59
src/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
360
src/components/admin/admin-dashboard.tsx
Normal file
360
src/components/admin/admin-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
src/components/admin/admin-notifications-panel.tsx
Normal file
220
src/components/admin/admin-notifications-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
311
src/components/admin/feature-review-dialog.tsx
Normal file
311
src/components/admin/feature-review-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.")
|
||||
}
|
||||
|
||||
339
src/components/features/feature-submission-form.tsx
Normal file
339
src/components/features/feature-submission-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// For unauthenticated users on non-auth pages, show header but redirect to auth
|
||||
// For all other pages, show header
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
@ -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}
|
||||
</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>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4 cursor-pointer">
|
||||
{/* While loading, don't show sign-in or user menu to avoid flicker */}
|
||||
{!isLoading && (!user ? (
|
||||
{!user ? (
|
||||
<Link href="/signin">
|
||||
<Button size="sm" className="cursor-pointer">Sign In</Button>
|
||||
</Link>
|
||||
@ -96,10 +110,10 @@ export default function Header() {
|
||||
</Badge>
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* User Menu - Only show when user is authenticated */}
|
||||
{!isLoading && user && user.email && (
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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">
|
||||
<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>
|
||||
{isAdmin && (
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@ -130,14 +150,19 @@ export default function Header() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoggingOut ? 'Logging out...' : 'Log out'}</span>
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 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" />}
|
||||
</Button>
|
||||
</div>
|
||||
@ -145,13 +170,13 @@ export default function Header() {
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-white/10">
|
||||
<div className="md:hidden py-4 border-t border-white/10">
|
||||
<nav className="flex flex-col space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
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
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
@ -161,7 +186,22 @@ export default function Header() {
|
||||
{item.name}
|
||||
</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>
|
||||
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal 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 }
|
||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal 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
139
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
@ -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<boolean>
|
||||
signup: (name: string, email: string, password: string) => Promise<boolean>
|
||||
isAdmin: boolean
|
||||
setUserFromApi: (user: User) => void
|
||||
logout: () => Promise<void>
|
||||
setUserFromApi: (apiUser: any) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(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<User | null>(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<boolean> => {
|
||||
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)
|
||||
// Always clear local data
|
||||
setUser(null)
|
||||
safeLocalStorage.removeItem('codenuk_user')
|
||||
safeLocalStorage.removeItem('accessToken')
|
||||
safeLocalStorage.removeItem('refreshToken')
|
||||
|
||||
// Redirect to signin page
|
||||
window.location.href = '/signin'
|
||||
}
|
||||
}
|
||||
|
||||
const signup = async (name: string, email: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<AuthContext.Provider value={value}>
|
||||
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}>
|
||||
{children}
|
||||
</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
203
src/lib/api/admin.ts
Normal 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
121
src/types/admin.types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user