312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
"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>
|
|
)
|
|
}
|