feat: Add Pending Photos management with API integration and UI updates
This commit introduces a new Pending Photos feature, allowing admins to manage user-uploaded photos awaiting review. A dedicated PendingPhotos page has been created in the frontend, which fetches and displays pending photos with options to approve or reject them. The backend has been updated with new API endpoints for listing and reviewing pending photos, ensuring seamless integration with the frontend. The Layout component has been modified to include navigation to the new Pending Photos page, enhancing the overall user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -15,6 +15,7 @@ import FacesMaintenance from './pages/FacesMaintenance'
|
||||
import ApproveIdentified from './pages/ApproveIdentified'
|
||||
import ManageUsers from './pages/ManageUsers'
|
||||
import ReportedPhotos from './pages/ReportedPhotos'
|
||||
import PendingPhotos from './pages/PendingPhotos'
|
||||
import Settings from './pages/Settings'
|
||||
import Help from './pages/Help'
|
||||
import Layout from './components/Layout'
|
||||
@@ -98,6 +99,14 @@ function AppRoutes() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="pending-photos"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PendingPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="help" element={<Help />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PendingPhotoResponse {
|
||||
id: number
|
||||
user_id: number
|
||||
user_name: string | null
|
||||
user_email: string | null
|
||||
filename: string
|
||||
original_filename: string
|
||||
file_path: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
status: string
|
||||
submitted_at: string
|
||||
reviewed_at: string | null
|
||||
reviewed_by: number | null
|
||||
rejection_reason: string | null
|
||||
}
|
||||
|
||||
export interface PendingPhotosListResponse {
|
||||
items: PendingPhotoResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ReviewDecision {
|
||||
id: number
|
||||
decision: 'approve' | 'reject'
|
||||
rejection_reason?: string | null
|
||||
}
|
||||
|
||||
export interface ReviewRequest {
|
||||
decisions: ReviewDecision[]
|
||||
}
|
||||
|
||||
export interface ReviewResponse {
|
||||
approved: number
|
||||
rejected: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const pendingPhotosApi = {
|
||||
listPendingPhotos: async (statusFilter?: string): Promise<PendingPhotosListResponse> => {
|
||||
const { data } = await apiClient.get<PendingPhotosListResponse>(
|
||||
'/api/v1/pending-photos',
|
||||
{
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
getPendingPhotoImage: (photoId: number): string => {
|
||||
return `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photoId}/image`
|
||||
},
|
||||
|
||||
getPendingPhotoImageBlob: async (photoId: number): Promise<string> => {
|
||||
// Fetch image as blob with authentication
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/pending-photos/${photoId}/image`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
}
|
||||
)
|
||||
// Create object URL from blob
|
||||
return URL.createObjectURL(response.data)
|
||||
},
|
||||
|
||||
reviewPendingPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
|
||||
const { data } = await apiClient.post<ReviewResponse>(
|
||||
'/api/v1/pending-photos/review',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function Layout() {
|
||||
{ path: '/approve-identified', label: 'Approve identified', icon: '✅', adminOnly: true },
|
||||
{ path: '/manage-users', label: 'Manage users', icon: '👥', adminOnly: true },
|
||||
{ path: '/reported-photos', label: 'Reported photos', icon: '🚩', adminOnly: true },
|
||||
{ path: '/pending-photos', label: 'Manage User Uploaded Photos', icon: '📤', adminOnly: true },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️', adminOnly: false },
|
||||
{ path: '/help', label: 'Help', icon: '📚', adminOnly: false },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
pendingPhotosApi,
|
||||
PendingPhotoResponse,
|
||||
ReviewDecision,
|
||||
} from '../api/pendingPhotos'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
export default function PendingPhotos() {
|
||||
const [pendingPhotos, setPendingPhotos] = useState<PendingPhotoResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'reject' | null>>({})
|
||||
const [rejectionReasons, setRejectionReasons] = useState<Record<number, string>>({})
|
||||
const [bulkRejectionReason, setBulkRejectionReason] = useState<string>('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
|
||||
const imageUrlsRef = useRef<Record<number, string>>({})
|
||||
|
||||
const loadPendingPhotos = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingPhotosApi.listPendingPhotos(
|
||||
statusFilter || undefined
|
||||
)
|
||||
setPendingPhotos(response.items)
|
||||
|
||||
// Clear decisions when loading different status
|
||||
setDecisions({})
|
||||
setRejectionReasons({})
|
||||
|
||||
// Load images as blobs with authentication
|
||||
const newImageUrls: Record<number, string> = {}
|
||||
for (const photo of response.items) {
|
||||
try {
|
||||
const blobUrl = await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
|
||||
newImageUrls[photo.id] = blobUrl
|
||||
} catch (err) {
|
||||
console.error(`Failed to load image for photo ${photo.id}:`, err)
|
||||
}
|
||||
}
|
||||
setImageUrls(newImageUrls)
|
||||
imageUrlsRef.current = newImageUrls
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load pending photos')
|
||||
console.error('Error loading pending photos:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(imageUrlsRef.current).forEach((url) => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingPhotos()
|
||||
}, [loadPendingPhotos])
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleDecisionChange = (id: number, decision: 'approve' | 'reject') => {
|
||||
const currentDecision = decisions[id]
|
||||
const isUnselecting = currentDecision === decision
|
||||
|
||||
setDecisions((prev) => {
|
||||
// If clicking the same option, unselect it
|
||||
if (prev[id] === decision) {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
}
|
||||
// Otherwise, set the new decision (this automatically unchecks the other checkbox)
|
||||
return {
|
||||
...prev,
|
||||
[id]: decision,
|
||||
}
|
||||
})
|
||||
|
||||
// Handle rejection reasons
|
||||
if (isUnselecting) {
|
||||
// Unselecting - clear rejection reason
|
||||
setRejectionReasons((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
})
|
||||
} else if (decision === 'approve') {
|
||||
// Switching to approve - clear rejection reason
|
||||
setRejectionReasons((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
})
|
||||
} else if (decision === 'reject' && bulkRejectionReason.trim()) {
|
||||
// Switching to reject - apply bulk rejection reason if set
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
[id]: bulkRejectionReason,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectionReasonChange = (id: number, reason: string) => {
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
[id]: reason,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAllApprove = () => {
|
||||
const pendingPhotoIds = pendingPhotos
|
||||
.filter((photo) => photo.status === 'pending')
|
||||
.map((photo) => photo.id)
|
||||
|
||||
const newDecisions: Record<number, 'approve'> = {}
|
||||
pendingPhotoIds.forEach((id) => {
|
||||
newDecisions[id] = 'approve'
|
||||
})
|
||||
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
...newDecisions,
|
||||
}))
|
||||
|
||||
// Clear all rejection reasons and bulk rejection reason since we're approving
|
||||
setRejectionReasons({})
|
||||
setBulkRejectionReason('')
|
||||
}
|
||||
|
||||
const handleSelectAllReject = () => {
|
||||
const pendingPhotoIds = pendingPhotos
|
||||
.filter((photo) => photo.status === 'pending')
|
||||
.map((photo) => photo.id)
|
||||
|
||||
const newDecisions: Record<number, 'reject'> = {}
|
||||
pendingPhotoIds.forEach((id) => {
|
||||
newDecisions[id] = 'reject'
|
||||
})
|
||||
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
...newDecisions,
|
||||
}))
|
||||
|
||||
// Apply bulk rejection reason if set
|
||||
if (bulkRejectionReason.trim()) {
|
||||
const newRejectionReasons: Record<number, string> = {}
|
||||
pendingPhotoIds.forEach((id) => {
|
||||
newRejectionReasons[id] = bulkRejectionReason
|
||||
})
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
...newRejectionReasons,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkRejectionReasonChange = (reason: string) => {
|
||||
setBulkRejectionReason(reason)
|
||||
|
||||
// Apply to all currently rejected photos
|
||||
const rejectedPhotoIds = Object.entries(decisions)
|
||||
.filter(([id, decision]) => decision === 'reject')
|
||||
.map(([id]) => parseInt(id))
|
||||
|
||||
if (rejectedPhotoIds.length > 0) {
|
||||
const newRejectionReasons: Record<number, string> = {}
|
||||
rejectedPhotoIds.forEach((id) => {
|
||||
newRejectionReasons[id] = reason
|
||||
})
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
...newRejectionReasons,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Get all decisions that have been made for pending items
|
||||
const decisionsList: ReviewDecision[] = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const photo = pendingPhotos.find((p) => p.id === parseInt(id))
|
||||
return decision !== null && photo && photo.status === 'pending'
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: parseInt(id),
|
||||
decision: decision!,
|
||||
rejection_reason: decision === 'reject' ? (rejectionReasons[parseInt(id)] || null) : null,
|
||||
}))
|
||||
|
||||
if (decisionsList.length === 0) {
|
||||
alert('Please select Approve or Reject for at least one pending photo.')
|
||||
return
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
const approveCount = decisionsList.filter((d) => d.decision === 'approve').length
|
||||
const rejectCount = decisionsList.filter((d) => d.decision === 'reject').length
|
||||
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will approve ${approveCount} photo(s) and reject ${rejectCount} photo(s).`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await pendingPhotosApi.reviewPendingPhotos({
|
||||
decisions: decisionsList,
|
||||
})
|
||||
|
||||
const message = [
|
||||
`✅ Approved: ${response.approved}`,
|
||||
`❌ Rejected: ${response.rejected}`,
|
||||
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(message)
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list to show updated status
|
||||
await loadPendingPhotos()
|
||||
// Clear decisions and reasons
|
||||
setDecisions({})
|
||||
setRejectionReasons({})
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to submit decisions'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error submitting decisions:', err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage User Uploaded Photos</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading pending photos...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
<button
|
||||
onClick={loadPendingPhotos}
|
||||
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total photos: <span className="font-semibold">{pendingPhotos.length}</span>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
submitting ||
|
||||
Object.values(decisions).filter((d) => d !== null).length === 0
|
||||
}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{Object.values(decisions).some((d) => d === 'reject') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700 font-medium whitespace-nowrap">
|
||||
Bulk Rejection Reason:
|
||||
</label>
|
||||
<textarea
|
||||
value={bulkRejectionReason}
|
||||
onChange={(e) => handleBulkRejectionReasonChange(e.target.value)}
|
||||
placeholder="Enter rejection reason to apply to all rejected photos..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingPhotos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No pending photos found.</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">
|
||||
Photo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Uploaded By
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
File Info
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Submitted At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Decision
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rejection Reason
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pendingPhotos.map((photo) => {
|
||||
const isPending = photo.status === 'pending'
|
||||
const isApproved = photo.status === 'approved'
|
||||
const isRejected = photo.status === 'rejected'
|
||||
const canMakeDecision = isPending
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={photo.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isApproved || isRejected ? 'opacity-60 bg-gray-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={async () => {
|
||||
// For full-size view, fetch as blob and open in new tab
|
||||
try {
|
||||
const blobUrl = imageUrls[photo.id] || await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
|
||||
// Create a new window with the blob URL
|
||||
const newWindow = window.open()
|
||||
if (newWindow) {
|
||||
newWindow.location.href = blobUrl
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open full-size image:', err)
|
||||
alert('Failed to load full-size image')
|
||||
}
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
{imageUrls[photo.id] ? (
|
||||
<img
|
||||
src={imageUrls[photo.id]}
|
||||
alt={photo.original_filename}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className =
|
||||
'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = 'Image not found'
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{photo.user_name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{photo.user_email || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900">
|
||||
{photo.original_filename}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatFileSize(photo.file_size)} • {photo.mime_type}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(photo.submitted_at)}
|
||||
</div>
|
||||
{photo.reviewed_at && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Reviewed: {formatDate(photo.reviewed_at)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
photo.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: photo.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{photo.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{canMakeDecision ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decisions[photo.id] === 'approve'}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
handleDecisionChange(photo.id, 'approve')
|
||||
} else {
|
||||
// Unchecking - remove decision
|
||||
handleDecisionChange(photo.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="checkbox"
|
||||
checked={decisions[photo.id] === 'reject'}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
handleDecisionChange(photo.id, 'reject')
|
||||
} else {
|
||||
// Unchecking - remove decision
|
||||
handleDecisionChange(photo.id, 'reject')
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Reject</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 italic">
|
||||
{isApproved ? 'Approved' : isRejected ? 'Rejected' : '-'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{canMakeDecision && decisions[photo.id] === 'reject' ? (
|
||||
<textarea
|
||||
value={rejectionReasons[photo.id] || ''}
|
||||
onChange={(e) =>
|
||||
handleRejectionReasonChange(photo.id, e.target.value)
|
||||
}
|
||||
placeholder="Optional: Enter rejection reason..."
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={2}
|
||||
/>
|
||||
) : isRejected && photo.rejection_reason ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
<div className="bg-red-50 p-2 rounded border border-red-200">
|
||||
{photo.rejection_reason}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user