feat: Add identification report and clear denied records functionality

This commit introduces new API endpoints for generating identification reports and clearing denied records from the database. The frontend has been updated to include a report button that fetches user identification statistics, allowing admins to view how many faces each user identified over a specified date range. Additionally, a clear denied records button has been added to permanently remove all denied identifications. The necessary data models and response structures have been implemented to support these features. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-24 14:58:11 -05:00
parent dbffaef298
commit a036169b0f
10 changed files with 673 additions and 38 deletions
@@ -37,6 +37,27 @@ export interface ApproveDenyResponse {
errors: string[]
}
export interface UserIdentificationStats {
user_id: number
username: string
full_name: string
email: string
face_count: number
first_identification_date: string | null
last_identification_date: string | null
}
export interface IdentificationReportResponse {
items: UserIdentificationStats[]
total_faces: number
total_users: number
}
export interface ClearDatabaseResponse {
deleted_records: number
errors: string[]
}
export const pendingIdentificationsApi = {
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
const res = await apiClient.get<PendingIdentificationsListResponse>(
@@ -52,6 +73,22 @@ export const pendingIdentificationsApi = {
)
return res.data
},
getReport: async (dateFrom?: string, dateTo?: string): Promise<IdentificationReportResponse> => {
const params: Record<string, string> = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const res = await apiClient.get<IdentificationReportResponse>(
'/api/v1/pending-identifications/report',
{ params }
)
return res.data
},
clearDenied: async (): Promise<ClearDatabaseResponse> => {
const res = await apiClient.post<ClearDatabaseResponse>(
'/api/v1/pending-identifications/clear-denied'
)
return res.data
},
}
export default pendingIdentificationsApi
+290 -16
View File
@@ -1,5 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import pendingIdentificationsApi, { PendingIdentification } from '../api/pendingIdentifications'
import pendingIdentificationsApi, {
PendingIdentification,
IdentificationReportResponse,
UserIdentificationStats
} from '../api/pendingIdentifications'
import { apiClient } from '../api/client'
export default function ApproveIdentified() {
@@ -9,6 +13,13 @@ export default function ApproveIdentified() {
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
const [submitting, setSubmitting] = useState(false)
const [includeDenied, setIncludeDenied] = useState(false)
const [showReport, setShowReport] = useState(false)
const [reportData, setReportData] = useState<IdentificationReportResponse | null>(null)
const [reportLoading, setReportLoading] = useState(false)
const [reportError, setReportError] = useState<string | null>(null)
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [clearing, setClearing] = useState(false)
const loadPendingIdentifications = useCallback(async () => {
setLoading(true)
@@ -51,10 +62,20 @@ export default function ApproveIdentified() {
}
const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => {
setDecisions(prev => ({
...prev,
[id]: decision
}))
setDecisions(prev => {
const currentDecision = prev[id]
// If clicking the same checkbox, deselect it
if (currentDecision === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this will automatically deselect the other)
return {
...prev,
[id]: decision
}
})
}
const handleSubmit = async () => {
@@ -109,6 +130,78 @@ export default function ApproveIdentified() {
}
}
const loadReport = useCallback(async () => {
setReportLoading(true)
setReportError(null)
try {
const response = await pendingIdentificationsApi.getReport(
dateFrom || undefined,
dateTo || undefined
)
setReportData(response)
} catch (err: any) {
setReportError(err.response?.data?.detail || err.message || 'Failed to load report')
console.error('Error loading report:', err)
} finally {
setReportLoading(false)
}
}, [dateFrom, dateTo])
const handleOpenReport = () => {
setShowReport(true)
loadReport()
}
const handleCloseReport = () => {
setShowReport(false)
setReportData(null)
setReportError(null)
setDateFrom('')
setDateTo('')
}
const formatDateTime = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const handleClearDenied = async () => {
if (!confirm('Are you sure you want to delete all denied records? This action cannot be undone.')) {
return
}
setClearing(true)
try {
const response = await pendingIdentificationsApi.clearDenied()
const message = [
`✅ Deleted ${response.deleted_records} denied record(s)`,
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
].filter(Boolean).join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
alert('Errors:\n' + response.errors.join('\n'))
}
// Reload the list to reflect changes
await loadPendingIdentifications()
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to clear denied records'
alert(`Error: ${errorMessage}`)
console.error('Error clearing denied records:', err)
} finally {
setClearing(false)
}
}
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
@@ -126,7 +219,7 @@ export default function ApproveIdentified() {
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingIdentifications}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
className="mt-3 px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Retry
</button>
@@ -140,6 +233,20 @@ export default function ApproveIdentified() {
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
</div>
<div className="flex items-center gap-4">
<button
onClick={handleOpenReport}
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-md hover:bg-green-200 font-medium"
>
📊 Report
</button>
<button
onClick={handleClearDenied}
disabled={clearing}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title="Delete all denied records from the database"
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -275,23 +382,37 @@ export default function ApproveIdentified() {
<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"
type="checkbox"
checked={decisions[pending.id] === 'approve'}
onChange={() => handleDecisionChange(pending.id, 'approve')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'approve') {
// Deselect if already selected
handleDecisionChange(pending.id, 'approve')
} else {
// Select approve (this will deselect deny if selected)
handleDecisionChange(pending.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<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"
type="checkbox"
checked={decisions[pending.id] === 'deny'}
onChange={() => handleDecisionChange(pending.id, 'deny')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'deny') {
// Deselect if already selected
handleDecisionChange(pending.id, 'deny')
} else {
// Select deny (this will deselect approve if selected)
handleDecisionChange(pending.id, 'deny')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
@@ -309,6 +430,159 @@ export default function ApproveIdentified() {
</>
)}
</div>
{/* Report Modal */}
{showReport && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Identification Report</h2>
<button
onClick={handleCloseReport}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
>
×
</button>
</div>
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date From
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date To
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
onClick={loadReport}
disabled={reportLoading}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
>
{reportLoading ? 'Loading...' : 'Apply Filter'}
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{reportLoading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading report...</p>
</div>
)}
{reportError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading report</p>
<p className="text-sm mt-1">{reportError}</p>
</div>
)}
{!reportLoading && !reportError && reportData && (
<>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700">Total Users:</span>{' '}
<span className="text-gray-900">{reportData.total_users}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Total Faces:</span>{' '}
<span className="text-gray-900">{reportData.total_faces}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Average per User:</span>{' '}
<span className="text-gray-900">
{reportData.total_users > 0
? Math.round((reportData.total_faces / reportData.total_users) * 10) / 10
: 0}
</span>
</div>
</div>
</div>
{reportData.items.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No identifications found for the selected date range.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Faces Identified
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
First Identification
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Identification
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.items.map((stat: UserIdentificationStats) => (
<tr key={stat.user_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{stat.full_name}
</div>
<div className="text-sm text-gray-500">{stat.username}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{stat.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{stat.face_count}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.first_identification_date)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.last_identification_date)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
)
}
+18 -18
View File
@@ -427,24 +427,6 @@ export default function PendingPhotos() {
<option value="rejected">Rejected</option>
</select>
</div>
{isAdmin && (
<div className="flex items-center gap-2">
<button
onClick={() => handleCleanupFiles()}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded-md hover:bg-orange-200 font-medium"
title="Delete files from shared space for approved/rejected photos"
>
🗑️ Cleanup Files
</button>
<button
onClick={() => handleCleanupDatabase()}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 font-medium"
title="Delete all records from pending_photos table"
>
🗑️ Clear Database
</button>
</div>
)}
<div className="flex items-center gap-2">
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
<>
@@ -462,6 +444,24 @@ export default function PendingPhotos() {
</button>
</>
)}
{isAdmin && (
<>
<button
onClick={() => handleCleanupFiles()}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded-md hover:bg-orange-200 font-medium"
title="Delete files from shared space for approved/rejected photos"
>
🗑️ Cleanup Files
</button>
<button
onClick={() => handleCleanupDatabase()}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 font-medium"
title="Delete all records from pending_photos table"
>
🗑️ Clear Database
</button>
</>
)}
<button
onClick={handleSubmit}
disabled={