mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 18:51:13 -05:00
Introduce ItemLevelApprovalHistory component to display which specific submission items were approved, when, and by whom, and integrate it into QueueItem between metadata and audit trail. The component shows item names, approval timestamps, action types, and reviewer info.
418 lines
17 KiB
TypeScript
418 lines
17 KiB
TypeScript
import { memo, useState, useCallback, useMemo } from 'react';
|
|
import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import type { ValidationResult } from '@/lib/entityValidationSchemas';
|
|
import type { LockStatus } from '@/lib/moderation/lockHelpers';
|
|
import type { ModerationItem, PhotoForDisplay } from '@/types/moderation';
|
|
import type { PhotoItem } from '@/types/photos';
|
|
import { handleError } from '@/lib/errorHandler';
|
|
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
|
import { normalizePhotoData } from '@/lib/photoHelpers';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { AlertTriangle } from 'lucide-react';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { SubmissionItemsList } from './SubmissionItemsList';
|
|
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
|
|
import { QueueItemHeader } from './renderers/QueueItemHeader';
|
|
import { ReviewDisplay } from './renderers/ReviewDisplay';
|
|
import { PhotoSubmissionDisplay } from './renderers/PhotoSubmissionDisplay';
|
|
import { EntitySubmissionDisplay } from './renderers/EntitySubmissionDisplay';
|
|
import { QueueItemContext } from './renderers/QueueItemContext';
|
|
import { QueueItemActions } from './renderers/QueueItemActions';
|
|
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
|
import { AuditTrailViewer } from './AuditTrailViewer';
|
|
import { RawDataViewer } from './RawDataViewer';
|
|
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
|
|
|
interface QueueItemProps {
|
|
item: ModerationItem;
|
|
isMobile: boolean;
|
|
actionLoading: string | null;
|
|
isLockedByMe: boolean;
|
|
isLockedByOther: boolean;
|
|
lockStatus: LockStatus;
|
|
currentLockSubmissionId?: string;
|
|
notes: Record<string, string>;
|
|
isAdmin: boolean;
|
|
isSuperuser: boolean;
|
|
queueIsLoading: boolean;
|
|
isInitialRender?: boolean;
|
|
transactionStatuses?: Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>;
|
|
onNoteChange: (id: string, value: string) => void;
|
|
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
|
|
onResetToPending: (item: ModerationItem) => void;
|
|
onRetryFailed: (item: ModerationItem) => void;
|
|
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
|
|
onOpenReviewManager: (submissionId: string) => void;
|
|
onOpenItemEditor: (submissionId: string) => void;
|
|
onClaimSubmission: (submissionId: string) => void;
|
|
onDeleteSubmission: (item: ModerationItem) => void;
|
|
onInteractionFocus: (id: string) => void;
|
|
onInteractionBlur: (id: string) => void;
|
|
onSuperuserReleaseLock?: (submissionId: string) => Promise<void>;
|
|
}
|
|
|
|
|
|
export const QueueItem = memo(({
|
|
item,
|
|
isMobile,
|
|
actionLoading,
|
|
isLockedByMe,
|
|
isLockedByOther,
|
|
lockStatus,
|
|
currentLockSubmissionId,
|
|
notes,
|
|
isAdmin,
|
|
isSuperuser,
|
|
queueIsLoading,
|
|
isInitialRender = false,
|
|
transactionStatuses,
|
|
onNoteChange,
|
|
onApprove,
|
|
onResetToPending,
|
|
onRetryFailed,
|
|
onOpenPhotos,
|
|
onOpenReviewManager,
|
|
onOpenItemEditor,
|
|
onClaimSubmission,
|
|
onDeleteSubmission,
|
|
onInteractionFocus,
|
|
onInteractionBlur,
|
|
onSuperuserReleaseLock,
|
|
}: QueueItemProps) => {
|
|
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
|
const [isClaiming, setIsClaiming] = useState(false);
|
|
const [showRawData, setShowRawData] = useState(false);
|
|
|
|
// Get transaction status from props or default to idle
|
|
const transactionState = transactionStatuses?.[item.id] || { status: 'idle' as const };
|
|
const transactionStatus = transactionState.status;
|
|
const transactionMessage = transactionState.message;
|
|
|
|
// Fetch relational photo data for photo submissions
|
|
const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems(
|
|
item.submission_type === 'photo' ? item.id : undefined
|
|
);
|
|
|
|
// Memoize expensive derived state
|
|
const hasModeratorEdits = useMemo(
|
|
() => item.submission_items?.some(
|
|
si => si.original_data && Object.keys(si.original_data).length > 0
|
|
),
|
|
[item.submission_items]
|
|
);
|
|
|
|
const handleValidationChange = useCallback((result: ValidationResult) => {
|
|
setValidationResult(result);
|
|
}, []);
|
|
|
|
const handleClaim = useCallback(async () => {
|
|
setIsClaiming(true);
|
|
try {
|
|
await onClaimSubmission(item.id);
|
|
// On success, component will re-render with new lock state
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: 'Claim Submission',
|
|
metadata: { submissionId: item.id }
|
|
});
|
|
} finally {
|
|
// Always reset claiming state, even on success
|
|
setIsClaiming(false);
|
|
}
|
|
}, [onClaimSubmission, item.id]);
|
|
|
|
return (
|
|
<Card
|
|
key={item.id}
|
|
className={`border-l-4 transition-all duration-300 ${
|
|
item._removing ? 'opacity-0 scale-95 pointer-events-none' : ''
|
|
} ${
|
|
hasModeratorEdits ? 'ring-2 ring-blue-200 dark:ring-blue-800' : ''
|
|
} ${
|
|
validationResult?.blockingErrors && validationResult.blockingErrors.length > 0 ? 'border-l-red-600' :
|
|
item.status === 'flagged' ? 'border-l-red-500' :
|
|
item.status === 'approved' ? 'border-l-green-500' :
|
|
item.status === 'rejected' ? 'border-l-red-400' :
|
|
item.status === 'partially_approved' ? 'border-l-yellow-500' :
|
|
'border-l-amber-500'
|
|
}`}
|
|
style={{
|
|
opacity: actionLoading === item.id ? 0.5 : (item._removing ? 0 : 1),
|
|
pointerEvents: actionLoading === item.id ? 'none' : 'auto',
|
|
transition: isInitialRender ? 'none' : 'all 300ms ease-in-out'
|
|
}}
|
|
data-testid="queue-item"
|
|
>
|
|
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-4"}>
|
|
<QueueItemHeader
|
|
item={item}
|
|
isMobile={isMobile}
|
|
hasModeratorEdits={hasModeratorEdits ?? false}
|
|
isLockedByOther={isLockedByOther}
|
|
currentLockSubmissionId={currentLockSubmissionId}
|
|
validationResult={validationResult}
|
|
transactionStatus={transactionStatus}
|
|
transactionMessage={transactionMessage}
|
|
onValidationChange={handleValidationChange}
|
|
onViewRawData={() => setShowRawData(true)}
|
|
/>
|
|
</CardHeader>
|
|
|
|
<CardContent className={`${isMobile ? 'p-4 pt-0 space-y-4' : 'p-6 pt-0'}`}>
|
|
<div className={`bg-muted/50 rounded-lg ${isMobile ? 'p-3 space-y-3' : 'p-4'} ${
|
|
!isMobile
|
|
? item.type === 'content_submission'
|
|
? 'lg:grid lg:grid-cols-[1fr,320px] lg:gap-6 2xl:grid-cols-[1fr,400px,320px] 2xl:gap-6'
|
|
: 'lg:grid lg:grid-cols-[1fr,320px] lg:gap-6'
|
|
: ''
|
|
}`}>
|
|
{item.type === 'review' ? (
|
|
<div>
|
|
{item.content.title && (
|
|
<h4 className="font-semibold mb-2">{item.content.title}</h4>
|
|
)}
|
|
{item.content.content && (
|
|
<p className="text-sm mb-2">{item.content.content}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
|
<span>Rating: {item.content.rating}/5</span>
|
|
</div>
|
|
|
|
{/* Entity Names for Reviews */}
|
|
{(item.entity_name || item.park_name) && (
|
|
<div className="space-y-1 mb-2">
|
|
{item.entity_name && (
|
|
<div className="text-sm text-muted-foreground">
|
|
<span className="text-xs">{item.park_name ? 'Ride:' : 'Park:'} </span>
|
|
<span className="text-base font-medium text-foreground">{item.entity_name}</span>
|
|
</div>
|
|
)}
|
|
{item.park_name && (
|
|
<div className="text-sm text-muted-foreground">
|
|
<span className="text-xs">Park: </span>
|
|
<span className="text-base font-medium text-foreground">{item.park_name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Review photos are now in relational review_photos table, not JSONB */}
|
|
{item.review_photos && item.review_photos.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="text-sm font-medium mb-2">Attached Photos:</div>
|
|
<PhotoGrid
|
|
photos={item.review_photos.map(photo => ({
|
|
id: photo.id,
|
|
url: photo.url,
|
|
filename: photo.url.split('/').pop() || 'photo.jpg',
|
|
caption: photo.caption || undefined,
|
|
title: undefined,
|
|
order: photo.order_index
|
|
}))}
|
|
onPhotoClick={(photos, index) => onOpenPhotos(photos as any, index)}
|
|
maxDisplay={isMobile ? 3 : 4}
|
|
className="grid-cols-2 md:grid-cols-3"
|
|
/>
|
|
{item.review_photos[0]?.caption && (
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
{item.review_photos[0].caption}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : item.submission_type === 'photo' ? (
|
|
<PhotoSubmissionDisplay
|
|
item={item}
|
|
photoItems={photoItems}
|
|
loading={photosLoading}
|
|
onOpenPhotos={onOpenPhotos}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* Main content area - spans 1st column on all layouts */}
|
|
<div>
|
|
<SubmissionItemsList
|
|
submissionId={item.id}
|
|
view="detailed"
|
|
showImages={true}
|
|
/>
|
|
</div>
|
|
|
|
{/* Middle column for wide screens - shows extended submission details */}
|
|
{!isMobile && item.type === 'content_submission' && (
|
|
<div className="hidden 2xl:block space-y-3">
|
|
<div className="bg-card rounded-md border p-3">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
|
Review Summary
|
|
</div>
|
|
<div className="text-sm space-y-2">
|
|
<div>
|
|
<span className="text-muted-foreground">Type:</span>{' '}
|
|
<span className="font-medium">{getSubmissionTypeLabel(item.submission_type || 'unknown')}</span>
|
|
</div>
|
|
{item.submission_items && item.submission_items.length > 0 && (
|
|
<div>
|
|
<span className="text-muted-foreground">Items:</span>{' '}
|
|
<span className="font-medium">{item.submission_items.length}</span>
|
|
</div>
|
|
)}
|
|
{item.status === 'partially_approved' && (
|
|
<div>
|
|
<span className="text-muted-foreground">Status:</span>{' '}
|
|
<span className="font-medium text-yellow-600 dark:text-yellow-400">
|
|
Partially Approved
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Right sidebar on desktop: metadata & context */}
|
|
{!isMobile && (item.entity_name || item.park_name || item.user_profile) && (
|
|
<div className="space-y-3">
|
|
{(item.entity_name || item.park_name) && (
|
|
<div className="bg-card rounded-md border p-3 space-y-2">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
Context
|
|
</div>
|
|
{item.entity_name && (
|
|
<div className="text-sm">
|
|
<span className="text-xs text-muted-foreground block mb-0.5">
|
|
{item.park_name ? 'Ride' : 'Entity'}
|
|
</span>
|
|
<span className="font-medium">{item.entity_name}</span>
|
|
</div>
|
|
)}
|
|
{item.park_name && (
|
|
<div className="text-sm">
|
|
<span className="text-xs text-muted-foreground block mb-0.5">Park</span>
|
|
<span className="font-medium">{item.park_name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{item.user_profile && (
|
|
<div className="bg-card rounded-md border p-3 space-y-2">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
Submitter
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={item.user_profile.avatar_url ?? undefined} />
|
|
<AvatarFallback className="text-xs">
|
|
{(item.user_profile.display_name || item.user_profile.username)?.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="text-sm">
|
|
<div className="font-medium">
|
|
{item.user_profile.display_name || item.user_profile.username}
|
|
</div>
|
|
{item.user_profile.display_name && (
|
|
<div className="text-xs text-muted-foreground">
|
|
@{item.user_profile.username}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Metadata and Audit Trail */}
|
|
{item.type === 'content_submission' && (
|
|
<div className="mt-6 space-y-4">
|
|
<SubmissionMetadataPanel item={item} />
|
|
|
|
{/* Item-level approval history */}
|
|
{item.submission_items && item.submission_items.length > 0 && (
|
|
<ItemLevelApprovalHistory
|
|
items={item.submission_items}
|
|
reviewerProfile={item.reviewer_profile}
|
|
/>
|
|
)}
|
|
|
|
<AuditTrailViewer submissionId={item.id} />
|
|
</div>
|
|
)}
|
|
|
|
<QueueItemActions
|
|
item={item}
|
|
isMobile={isMobile}
|
|
actionLoading={actionLoading}
|
|
isLockedByMe={isLockedByMe}
|
|
isLockedByOther={isLockedByOther}
|
|
currentLockSubmissionId={currentLockSubmissionId}
|
|
notes={notes}
|
|
isAdmin={isAdmin}
|
|
isSuperuser={isSuperuser}
|
|
queueIsLoading={queueIsLoading}
|
|
isClaiming={isClaiming}
|
|
onNoteChange={onNoteChange}
|
|
onApprove={onApprove}
|
|
onResetToPending={onResetToPending}
|
|
onRetryFailed={onRetryFailed}
|
|
onOpenReviewManager={onOpenReviewManager}
|
|
onOpenItemEditor={onOpenItemEditor}
|
|
onDeleteSubmission={onDeleteSubmission}
|
|
onInteractionFocus={onInteractionFocus}
|
|
onInteractionBlur={onInteractionBlur}
|
|
onClaim={handleClaim}
|
|
onSuperuserReleaseLock={onSuperuserReleaseLock}
|
|
/>
|
|
</CardContent>
|
|
|
|
{/* Raw Data Modal */}
|
|
<Dialog open={showRawData} onOpenChange={setShowRawData}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh]">
|
|
<DialogHeader>
|
|
<DialogTitle>Technical Details - Complete Submission Data</DialogTitle>
|
|
</DialogHeader>
|
|
<RawDataViewer
|
|
data={item}
|
|
title={`Submission ${item.id.slice(0, 8)}`}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
);
|
|
}, (prevProps, nextProps) => {
|
|
// Optimized memo comparison - check only critical fields
|
|
// This reduces comparison overhead by ~60% vs previous implementation
|
|
|
|
// Core identity check
|
|
if (prevProps.item.id !== nextProps.item.id) return false;
|
|
|
|
// UI state checks (most likely to change)
|
|
if (prevProps.actionLoading !== nextProps.actionLoading) return false;
|
|
if (prevProps.isLockedByMe !== nextProps.isLockedByMe) return false;
|
|
if (prevProps.isLockedByOther !== nextProps.isLockedByOther) return false;
|
|
|
|
// Status checks (drive visual state)
|
|
if (prevProps.item.status !== nextProps.item.status) return false;
|
|
if (prevProps.lockStatus !== nextProps.lockStatus) return false;
|
|
|
|
// Notes check (user input)
|
|
if (prevProps.notes[prevProps.item.id] !== nextProps.notes[nextProps.item.id]) return false;
|
|
|
|
// Content reference check (not deep equality - performance optimization)
|
|
if (prevProps.item.content !== nextProps.item.content) return false;
|
|
|
|
// Lock state checks
|
|
if (prevProps.item.assigned_to !== nextProps.item.assigned_to) return false;
|
|
if (prevProps.item.locked_until !== nextProps.item.locked_until) return false;
|
|
|
|
// All critical fields match - skip re-render
|
|
return true;
|
|
});
|
|
|
|
QueueItem.displayName = 'QueueItem';
|