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.
This commit is contained in:
parent
d5d6dc82b1
commit
a888968a97
@ -235,12 +235,6 @@ 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"
|
||||
>
|
||||
📊 Statistics
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isAdmin) {
|
||||
@ -373,8 +367,11 @@ export default function ApproveIdentified() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{pending.user_name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{pending.user_name || pending.user_email}
|
||||
{pending.user_email || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
|
||||
@ -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<Set<number>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
@ -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<FaceItem[]>([])
|
||||
const [, setTotal] = useState(0)
|
||||
@ -59,6 +64,14 @@ export default function Identify() {
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
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<IdentificationReportResponse | null>(null)
|
||||
const [statsLoading, setStatsLoading] = useState(false)
|
||||
const [statsError, setStatsError] = useState<string | null>(null)
|
||||
const [statsDateFrom, setStatsDateFrom] = useState<string>('')
|
||||
const [statsDateTo, setStatsDateTo] = useState<string>('')
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
@ -257,6 +270,36 @@ export default function Identify() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Identify
|
||||
{photoIds && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Identify
|
||||
{photoIds && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenStats}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-md hover:bg-green-200 font-medium"
|
||||
>
|
||||
📊 Statistics
|
||||
</button>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Controls and current face */}
|
||||
@ -1100,6 +1154,146 @@ export default function Identify() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identification statistics modal */}
|
||||
{showStats && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={handleCloseStats}
|
||||
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={statsDateFrom}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={statsDateTo}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadStats}
|
||||
disabled={statsLoading}
|
||||
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"
|
||||
>
|
||||
{statsLoading ? 'Loading...' : 'Apply Filter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{statsLoading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading report...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statsError && (
|
||||
<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">{statsError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!statsLoading && !statsError && statsData && (
|
||||
<>
|
||||
<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">{statsData.total_users}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Total Faces:</span>{' '}
|
||||
<span className="text-gray-900">{statsData.total_faces}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Average per User:</span>{' '}
|
||||
<span className="text-gray-900">
|
||||
{statsData.total_users > 0
|
||||
? Math.round((statsData.total_faces / statsData.total_users) * 10) / 10
|
||||
: 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statsData.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">
|
||||
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">
|
||||
{statsData.items.map((item) => (
|
||||
<tr key={item.user_id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.full_name || item.username}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.face_count}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.first_identification_date || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.last_identification_date || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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<Record<number, string>>({})
|
||||
const [sortBy, setSortBy] = useState<SortKey>('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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px]">{directionSymbol}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
@ -430,22 +493,6 @@ export default function PendingPhotos() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSelectAllApprove}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectAllReject}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canManageUploads && (
|
||||
<>
|
||||
<button
|
||||
@ -456,7 +503,7 @@ export default function PendingPhotos() {
|
||||
handleCleanupFiles()
|
||||
}}
|
||||
disabled={!canRunCleanup}
|
||||
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded-md hover:bg-orange-200 font-medium"
|
||||
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
title={
|
||||
canRunCleanup
|
||||
? 'Delete files from shared space for approved/rejected photos'
|
||||
@ -473,7 +520,7 @@ export default function PendingPhotos() {
|
||||
handleCleanupDatabase()
|
||||
}}
|
||||
disabled={!canRunCleanup}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 font-medium"
|
||||
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
title={
|
||||
canRunCleanup
|
||||
? 'Delete all records from pending_photos table'
|
||||
@ -484,6 +531,22 @@ export default function PendingPhotos() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSelectAllApprove}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectAllReject}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
@ -521,20 +584,20 @@ export default function PendingPhotos() {
|
||||
<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">
|
||||
Photo
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Photo', 'photo')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Uploaded By
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Uploaded By', 'uploaded_by')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
File Info
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('File Info', 'file_info')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Submitted At
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Submitted At', 'submitted_at')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Status', 'status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Decision
|
||||
@ -545,7 +608,7 @@ export default function PendingPhotos() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pendingPhotos.map((photo) => {
|
||||
{sortedPendingPhotos.map((photo) => {
|
||||
const isPending = photo.status === 'pending'
|
||||
const isApproved = photo.status === 'approved'
|
||||
const isRejected = photo.status === 'rejected'
|
||||
|
||||
@ -9,6 +9,8 @@ import { useAuth } from '../context/AuthContext'
|
||||
|
||||
type DecisionValue = 'approve' | 'deny'
|
||||
|
||||
type SortKey = 'photo' | 'tag' | 'submitted_by' | 'submitted_at' | 'status'
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
@ -30,6 +32,8 @@ export default function UserTaggedPhotos() {
|
||||
const [decisions, setDecisions] = useState<Record<number, DecisionValue | null>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const loadLinkages = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -57,6 +61,71 @@ export default function UserTaggedPhotos() {
|
||||
[linkages]
|
||||
)
|
||||
|
||||
const sortedLinkages = useMemo(() => {
|
||||
const items = [...linkages]
|
||||
items.sort((a, b) => {
|
||||
const direction = sortDirection === 'asc' ? 1 : -1
|
||||
|
||||
const compareStrings = (x: string | null | undefined, y: string | null | undefined) =>
|
||||
(x || '').localeCompare(y || '', undefined, { sensitivity: 'base' })
|
||||
|
||||
if (sortBy === 'photo') {
|
||||
return (a.photo_id - b.photo_id) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'tag') {
|
||||
const aTag = a.resolved_tag_name || a.proposed_tag_name || ''
|
||||
const bTag = b.resolved_tag_name || b.proposed_tag_name || ''
|
||||
return compareStrings(aTag, bTag) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'submitted_by') {
|
||||
const aName = a.user_name || a.user_email || ''
|
||||
const bName = b.user_name || b.user_email || ''
|
||||
return compareStrings(aName, bName) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'submitted_at') {
|
||||
const aTime = a.created_at || ''
|
||||
const bTime = b.created_at || ''
|
||||
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'status') {
|
||||
return compareStrings(a.status, b.status) * direction
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
return items
|
||||
}, [linkages, 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px]">{directionSymbol}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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<number, DecisionValue> = {}
|
||||
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<number, DecisionValue> = {}
|
||||
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() {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{linkages.filter((item) => item.status === 'pending').length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAllApprove}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAllDeny}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Deny
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
@ -276,26 +404,26 @@ export default function UserTaggedPhotos() {
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Photo
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Photo', 'photo')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Proposed Tag
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Proposed Tag', 'tag')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Current Tags
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Submitted By
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Submitted By', 'submitted_by')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Submitted At
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Submitted At', 'submitted_at')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Status', 'status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Decision
|
||||
@ -303,7 +431,7 @@ export default function UserTaggedPhotos() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{linkages.map((linkage) => {
|
||||
{sortedLinkages.map((linkage) => {
|
||||
const canReview = linkage.status === 'pending'
|
||||
const decision = decisions[linkage.id] ?? null
|
||||
return (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user