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 && ( +
+ + +
+ )} +