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 && (
+
+ )}
+
+ {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.
+
+ ) : (
+
+
+
+
+ |
+ User
+ |
+
+ Faces Identified
+ |
+
+ First Identification
+ |
+
+ Last Identification
+ |
+
+
+
+ {statsData.items.map((item) => (
+
+ |
+ {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 && (
<>
|