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",
|
"@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",
|
||||||
|
|||||||
@ -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
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);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
|
// Redirect based on user role
|
||||||
|
if (response.data.user.role === 'admin') {
|
||||||
|
router.push("/admin")
|
||||||
|
} else {
|
||||||
router.push("/")
|
router.push("/")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError("Invalid response from server. Please try again.")
|
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"
|
"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 />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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"
|
"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")
|
|
||||||
if (userData) {
|
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(userData)
|
const userData = JSON.parse(stored)
|
||||||
// Ensure user object has all required properties with fallbacks
|
setUser(userData)
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error("Error parsing user data:", error)
|
console.error('Failed to parse stored user data:', error)
|
||||||
safeLocalStorage.removeItem("codenuk_user")
|
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')
|
||||||
|
safeLocalStorage.removeItem('refreshToken')
|
||||||
|
|
||||||
const signup = async (name: string, email: string, password: string): Promise<boolean> => {
|
// Redirect to signin page
|
||||||
try {
|
window.location.href = '/signin'
|
||||||
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 (
|
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
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