feat: Implement approve/deny functionality for pending identifications
This commit adds the ability to approve or deny pending identifications through a new API endpoint and updates the frontend to support this feature. The `PendingIdentification` interface has been extended to include an optional `photo_id`, and new request/response models for approval decisions have been introduced. The ApproveIdentified page now allows users to submit their decisions, with UI updates for better user interaction. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -3,6 +3,7 @@ import apiClient from './client'
|
||||
export interface PendingIdentification {
|
||||
id: number
|
||||
face_id: number
|
||||
photo_id?: number | null
|
||||
user_id: number
|
||||
user_name?: string | null
|
||||
user_email: string
|
||||
@@ -21,10 +22,33 @@ export interface PendingIdentificationsListResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ApproveDenyDecision {
|
||||
id: number
|
||||
decision: 'approve' | 'deny'
|
||||
}
|
||||
|
||||
export interface ApproveDenyRequest {
|
||||
decisions: ApproveDenyDecision[]
|
||||
}
|
||||
|
||||
export interface ApproveDenyResponse {
|
||||
approved: number
|
||||
denied: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const pendingIdentificationsApi = {
|
||||
list: async (): Promise<PendingIdentificationsListResponse> => {
|
||||
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
|
||||
const res = await apiClient.get<PendingIdentificationsListResponse>(
|
||||
'/api/v1/pending-identifications'
|
||||
'/api/v1/pending-identifications',
|
||||
{ params: { include_denied: includeDenied } }
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
approveDeny: async (request: ApproveDenyRequest): Promise<ApproveDenyResponse> => {
|
||||
const res = await apiClient.post<ApproveDenyResponse>(
|
||||
'/api/v1/pending-identifications/approve-deny',
|
||||
request
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import pendingIdentificationsApi, { PendingIdentification } from '../api/pendingIdentifications'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
export default function ApproveIdentified() {
|
||||
const [pendingIdentifications, setPendingIdentifications] = useState<PendingIdentification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [includeDenied, setIncludeDenied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingIdentifications()
|
||||
}, [])
|
||||
|
||||
const loadPendingIdentifications = async () => {
|
||||
const loadPendingIdentifications = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.list()
|
||||
const response = await pendingIdentificationsApi.list(includeDenied)
|
||||
setPendingIdentifications(response.items)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load pending identifications')
|
||||
@@ -22,7 +22,11 @@ export default function ApproveIdentified() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [includeDenied])
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingIdentifications()
|
||||
}, [loadPendingIdentifications])
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
@@ -46,6 +50,65 @@ export default function ApproveIdentified() {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => {
|
||||
setDecisions(prev => ({
|
||||
...prev,
|
||||
[id]: decision
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Get all decisions that have been made, but only for pending items
|
||||
const decisionsList = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const pending = pendingIdentifications.find(p => p.id === parseInt(id))
|
||||
return decision !== null && pending && pending.status === 'pending'
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: parseInt(id),
|
||||
decision: decision!
|
||||
}))
|
||||
|
||||
if (decisionsList.length === 0) {
|
||||
alert('Please select Approve or Deny for at least one identification.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Submit ${decisionsList.length} decision(s)?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.approveDeny({
|
||||
decisions: decisionsList
|
||||
})
|
||||
|
||||
const message = [
|
||||
`✅ Approved: ${response.approved}`,
|
||||
`❌ Denied: ${response.denied}`,
|
||||
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
alert(message)
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list to show updated status
|
||||
await loadPendingIdentifications()
|
||||
// Clear decisions
|
||||
setDecisions({})
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to submit decisions'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error submitting decisions:', err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
@@ -72,8 +135,28 @@ export default function ApproveIdentified() {
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDenied}
|
||||
onChange={(e) => setIncludeDenied(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Include denied</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || Object.values(decisions).filter(d => d !== null).length === 0}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingIdentifications.length === 0 ? (
|
||||
@@ -92,7 +175,7 @@ export default function ApproveIdentified() {
|
||||
Date of Birth
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Face ID
|
||||
Face
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
@@ -100,11 +183,17 @@ export default function ApproveIdentified() {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Approve
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pendingIdentifications.map((pending) => (
|
||||
<tr key={pending.id} className="hover:bg-gray-50">
|
||||
{pendingIdentifications.map((pending) => {
|
||||
const isDenied = pending.status === 'denied'
|
||||
const isApproved = pending.status === 'approved'
|
||||
return (
|
||||
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''} ${isApproved ? 'opacity-60 bg-green-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatName(pending)}
|
||||
@@ -116,8 +205,53 @@ export default function ApproveIdentified() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{pending.face_id}
|
||||
<div className="flex items-center">
|
||||
{pending.photo_id ? (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/faces/${pending.face_id}/crop`}
|
||||
alt={`Face ${pending.face_id}`}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${pending.face_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`/api/v1/faces/${pending.face_id}/crop`}
|
||||
alt={`Face ${pending.face_id}`}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${pending.face_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -130,8 +264,41 @@ export default function ApproveIdentified() {
|
||||
{formatDate(pending.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isDenied ? (
|
||||
<div className="text-sm text-red-600 font-medium">Denied</div>
|
||||
) : isApproved ? (
|
||||
<div className="text-sm text-green-600 font-medium">Approved</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="approve"
|
||||
checked={decisions[pending.id] === 'approve'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'approve')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="deny"
|
||||
checked={decisions[pending.id] === 'deny'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'deny')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user