From a888968a976586e3d77e374550e6fbe3fc77f785 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 1 Dec 2025 10:30:10 -0500 Subject: [PATCH] feat: Implement identification statistics modal and sorting for user-tagged photos This commit introduces a new modal for displaying identification statistics, allowing admins to filter reports by date range. The Identify component has been updated to include state management for the modal and loading logic for the statistics data. Additionally, sorting functionality has been added to the User Tagged Photos page, enabling users to sort by various fields such as photo, tag, and submitted date. The UI has been enhanced with buttons for selecting all pending decisions, improving the user experience. Documentation has been updated to reflect these changes. --- frontend/src/pages/ApproveIdentified.tsx | 11 +- frontend/src/pages/FacesMaintenance.tsx | 2 +- frontend/src/pages/Identify.tsx | 210 ++++++++++++++++++++++- frontend/src/pages/ManageUsers.tsx | 8 +- frontend/src/pages/PendingPhotos.tsx | 135 +++++++++++---- frontend/src/pages/UserTaggedPhotos.tsx | 150 ++++++++++++++-- 6 files changed, 451 insertions(+), 65 deletions(-) diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx index ce2369a..db0f2ff 100644 --- a/frontend/src/pages/ApproveIdentified.tsx +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -235,12 +235,6 @@ export default function ApproveIdentified() { Total pending identifications: {pendingIdentifications.length}
-
+
+ {pending.user_name || 'Unknown'} +
- {pending.user_name || pending.user_email} + {pending.user_email || '-'}
diff --git a/frontend/src/pages/FacesMaintenance.tsx b/frontend/src/pages/FacesMaintenance.tsx index b723769..f58954a 100644 --- a/frontend/src/pages/FacesMaintenance.tsx +++ b/frontend/src/pages/FacesMaintenance.tsx @@ -10,7 +10,7 @@ export default function FacesMaintenance() { const [total, setTotal] = useState(0) const [pageSize, setPageSize] = useState(50) const [minQuality, setMinQuality] = useState(0.0) - const [maxQuality, setMaxQuality] = useState(1.0) + const [maxQuality, setMaxQuality] = useState(0.45) const [selectedFaces, setSelectedFaces] = useState>(new Set()) const [loading, setLoading] = useState(false) const [deleting, setDeleting] = useState(false) diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 39024e0..9ac3af4 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -1,16 +1,21 @@ -import { useEffect, useMemo, useState, useRef } from 'react' +import { useEffect, useMemo, useState, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces' import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' import tagsApi, { TagResponse } from '../api/tags' import { useDeveloperMode } from '../context/DeveloperModeContext' +import { useAuth } from '../context/AuthContext' +import pendingIdentificationsApi, { + IdentificationReportResponse, +} from '../api/pendingIdentifications' type SortBy = 'quality' | 'date_taken' | 'date_added' type SortDir = 'asc' | 'desc' export default function Identify() { const { isDeveloperMode } = useDeveloperMode() + const { isAdmin } = useAuth() const [searchParams, setSearchParams] = useSearchParams() const [faces, setFaces] = useState([]) const [, setTotal] = useState(0) @@ -59,6 +64,14 @@ export default function Identify() { const [selectedTags, setSelectedTags] = useState([]) const [tagsExpanded, setTagsExpanded] = useState(false) const [selectKey, setSelectKey] = useState(0) // Key to force select re-render when clearing + + // Identification statistics modal state + const [showStats, setShowStats] = useState(false) + const [statsData, setStatsData] = useState(null) + const [statsLoading, setStatsLoading] = useState(false) + const [statsError, setStatsError] = useState(null) + const [statsDateFrom, setStatsDateFrom] = useState('') + const [statsDateTo, setStatsDateTo] = useState('') // Store form data per face ID (matching desktop behavior) const [faceFormData, setFaceFormData] = useState { + setStatsLoading(true) + setStatsError(null) + try { + const response = await pendingIdentificationsApi.getReport( + statsDateFrom || undefined, + statsDateTo || undefined + ) + setStatsData(response) + } catch (error: any) { + setStatsError(error.response?.data?.detail || error.message || 'Failed to load report') + console.error('Error loading identification report:', error) + } finally { + setStatsLoading(false) + } + }, [statsDateFrom, statsDateTo]) + + const handleOpenStats = () => { + setShowStats(true) + loadStats() + } + + const handleCloseStats = () => { + setShowStats(false) + setStatsData(null) + setStatsError(null) + setStatsDateFrom('') + setStatsDateTo('') + } + const loadSimilar = async (faceId: number) => { if (!compareEnabled) { setSimilar([]) @@ -650,14 +693,25 @@ export default function Identify() { return (
-

- Identify - {photoIds && ( - - (Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''}) - +
+

+ Identify + {photoIds && ( + + (Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''}) + + )} +

+ {isAdmin && ( + )} -

+
{/* Left: Controls and current face */} @@ -1100,6 +1154,146 @@ export default function Identify() {
+ + {/* Identification statistics modal */} + {showStats && ( +
+
+
+

Identification Report

+ +
+ +
+
+
+ + setStatsDateFrom(event.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" + /> +
+
+ + setStatsDateTo(event.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" + /> +
+
+ +
+
+
+ +
+ {statsLoading && ( +
+

Loading report...

+
+ )} + + {statsError && ( +
+

Error loading report

+

{statsError}

+
+ )} + + {!statsLoading && !statsError && statsData && ( + <> +
+
+
+ Total Users:{' '} + {statsData.total_users} +
+
+ Total Faces:{' '} + {statsData.total_faces} +
+
+ Average per User:{' '} + + {statsData.total_users > 0 + ? Math.round((statsData.total_faces / statsData.total_users) * 10) / 10 + : 0} + +
+
+
+ + {statsData.items.length === 0 ? ( +
+

No identifications found for the selected date range.

+
+ ) : ( +
+ + + + + + + + + + + {statsData.items.map((item) => ( + + + + + + + ))} + +
+ User + + Faces Identified + + First Identification + + Last Identification +
+ {item.full_name || item.username} + + {item.face_count} + + {item.first_identification_date || '-'} + + {item.last_identification_date || '-'} +
+
+ )} + + )} +
+
+
+ )} ) } diff --git a/frontend/src/pages/ManageUsers.tsx b/frontend/src/pages/ManageUsers.tsx index 1a8a920..57804b5 100644 --- a/frontend/src/pages/ManageUsers.tsx +++ b/frontend/src/pages/ManageUsers.tsx @@ -608,10 +608,14 @@ const getDisplayRoleLabel = (user: UserResponse): string => { } const filteredUsers = useMemo(() => { + // Hide the special system user used for frontend approvals + const visibleUsers = users.filter((user) => user.username !== 'FrontEndUser') + if (filterRole === null) { - return users + return visibleUsers } - return users.filter( + + return visibleUsers.filter( (user) => normalizeRoleValue(user.role ?? null, user.is_admin) === filterRole ) }, [users, filterRole]) diff --git a/frontend/src/pages/PendingPhotos.tsx b/frontend/src/pages/PendingPhotos.tsx index 320afce..f504a34 100644 --- a/frontend/src/pages/PendingPhotos.tsx +++ b/frontend/src/pages/PendingPhotos.tsx @@ -1,13 +1,10 @@ -import { useEffect, useState, useCallback, useRef } from 'react' -import { - pendingPhotosApi, - PendingPhotoResponse, - ReviewDecision, - CleanupResponse, -} from '../api/pendingPhotos' +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos' import { apiClient } from '../api/client' import { useAuth } from '../context/AuthContext' +type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status' + export default function PendingPhotos() { const { hasPermission, isAdmin } = useAuth() const canManageUploads = hasPermission('user_uploaded') @@ -28,6 +25,8 @@ export default function PendingPhotos() { errors: string[] } | null>(null) const imageUrlsRef = useRef>({}) + const [sortBy, setSortBy] = useState('submitted_at') + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') const loadPendingPhotos = useCallback(async () => { setLoading(true) @@ -75,6 +74,70 @@ export default function PendingPhotos() { loadPendingPhotos() }, [loadPendingPhotos]) + const sortedPendingPhotos = useMemo(() => { + const items = [...pendingPhotos] + const direction = sortDirection === 'asc' ? 1 : -1 + + const compareStrings = (a: string | null | undefined, b: string | null | undefined) => + (a || '').localeCompare(b || '', undefined, { sensitivity: 'base' }) + + items.sort((a, b) => { + if (sortBy === 'photo') { + return (a.id - b.id) * direction + } + + if (sortBy === 'uploaded_by') { + const aName = a.user_name || a.user_email || '' + const bName = b.user_name || b.user_email || '' + return compareStrings(aName, bName) * direction + } + + if (sortBy === 'file_info') { + return compareStrings(a.original_filename, b.original_filename) * direction + } + + if (sortBy === 'submitted_at') { + const aTime = a.submitted_at || '' + const bTime = b.submitted_at || '' + return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction + } + + if (sortBy === 'status') { + return compareStrings(a.status, b.status) * direction + } + + return 0 + }) + + return items + }, [pendingPhotos, sortBy, sortDirection]) + + const toggleSort = (key: SortKey) => { + setSortBy((currentKey) => { + if (currentKey === key) { + setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc')) + return currentKey + } + setSortDirection('asc') + return key + }) + } + + const renderSortLabel = (label: string, key: SortKey) => { + const isActive = sortBy === key + const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼' + return ( + + ) + } + const formatDate = (dateString: string | null | undefined): string => { if (!dateString) return '-' try { @@ -430,22 +493,6 @@ export default function PendingPhotos() {
- {pendingPhotos.filter((p) => p.status === 'pending').length > 0 && ( - <> - - - - )} {canManageUploads && ( <> + + + )} + ) + } + const hasPendingDecision = useMemo( () => Object.entries(decisions).some(([id, value]) => { @@ -77,6 +146,46 @@ export default function UserTaggedPhotos() { }) } + const handleSelectAllApprove = () => { + const pendingIds = linkages + .filter((item) => item.status === 'pending') + .map((item) => item.id) + + if (pendingIds.length === 0) { + return + } + + const newDecisions: Record = {} + pendingIds.forEach((id) => { + newDecisions[id] = 'approve' + }) + + setDecisions((prev) => ({ + ...prev, + ...newDecisions, + })) + } + + const handleSelectAllDeny = () => { + const pendingIds = linkages + .filter((item) => item.status === 'pending') + .map((item) => item.id) + + if (pendingIds.length === 0) { + return + } + + const newDecisions: Record = {} + pendingIds.forEach((id) => { + newDecisions[id] = 'deny' + }) + + setDecisions((prev) => ({ + ...prev, + ...newDecisions, + })) + } + const handleSubmit = async () => { const decisionsList: ReviewDecision[] = Object.entries(decisions) .filter(([id, decision]) => { @@ -220,6 +329,25 @@ export default function UserTaggedPhotos() {
+ {linkages.filter((item) => item.status === 'pending').length > 0 && ( +
+ + +
+ )} +