Compare commits

..

3 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
c8bea4b798 Changes 2025-11-12 14:36:37 +00:00
gpt-engineer-app[bot]
250e7c488a Changes 2025-11-12 14:36:07 +00:00
gpt-engineer-app[bot]
46c08e10e8 Add item-level approval history
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.
2025-11-12 14:28:50 +00:00
5 changed files with 250 additions and 25 deletions

View File

@@ -0,0 +1,54 @@
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface DetailedViewCollapsibleProps {
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
className?: string;
}
/**
* Collapsible wrapper for detailed field-by-field view sections
* Provides expand/collapse functionality with visual indicators
*/
export function DetailedViewCollapsible({
isCollapsed,
onToggle,
children,
className
}: DetailedViewCollapsibleProps) {
return (
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
<div className={cn("mt-6 pt-6 border-t", className)}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto"
>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
All Fields (Detailed View)
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground normal-case font-normal">
{isCollapsed ? 'Show' : 'Hide'}
</span>
{isCollapsed ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
{children}
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,125 @@
import { memo } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, User } from 'lucide-react';
import type { SubmissionItem } from '@/types/moderation';
interface ItemLevelApprovalHistoryProps {
items: SubmissionItem[];
reviewerProfile?: {
user_id: string;
username: string;
display_name?: string | null;
avatar_url?: string | null;
} | null;
}
export const ItemLevelApprovalHistory = memo(({
items,
reviewerProfile,
}: ItemLevelApprovalHistoryProps) => {
// Filter to only approved items with timestamps
const approvedItems = items.filter(
item => item.status === 'approved' && (item as any).approved_at
);
if (approvedItems.length === 0) {
return null;
}
// Sort by approval time (newest first)
const sortedItems = [...approvedItems].sort((a, b) => {
const timeA = new Date((a as any).approved_at).getTime();
const timeB = new Date((b as any).approved_at).getTime();
return timeB - timeA;
});
// Helper to get item display name
const getItemName = (item: SubmissionItem): string => {
const entityData = item.entity_data || item.item_data;
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
return String(entityData.name);
}
return `${item.item_type} #${item.order_index}`;
};
// Helper to get action label
const getActionLabel = (actionType: string): string => {
switch (actionType) {
case 'create': return 'Created';
case 'edit': return 'Edited';
case 'delete': return 'Deleted';
default: return 'Modified';
}
};
return (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5" />
Item Approvals
</div>
<div className="space-y-2">
{sortedItems.map((item) => {
const approvedAt = (item as any).approved_at;
const itemName = getItemName(item);
const actionLabel = getActionLabel(item.action_type);
return (
<div
key={item.id}
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
>
{/* Approval Icon */}
<div className="flex-shrink-0 mt-0.5">
<CheckCircle2 className="h-4 w-4 text-success" />
</div>
{/* Item Info */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground truncate">
{itemName}
</span>
<Badge variant="outline" className="text-xs">
{actionLabel}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{item.item_type}
</Badge>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
</span>
</div>
{/* Reviewer Info */}
{reviewerProfile && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar className="h-5 w-5">
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
<AvatarFallback className="text-[10px]">
<User className="h-3 w-3" />
</AvatarFallback>
</Avatar>
<span>
Approved by{' '}
<span className="font-medium text-foreground">
{reviewerProfile.display_name || reviewerProfile.username}
</span>
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
});
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';

View File

@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel'; import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
import { AuditTrailViewer } from './AuditTrailViewer'; import { AuditTrailViewer } from './AuditTrailViewer';
import { RawDataViewer } from './RawDataViewer'; import { RawDataViewer } from './RawDataViewer';
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
interface QueueItemProps { interface QueueItemProps {
item: ModerationItem; item: ModerationItem;
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
{item.type === 'content_submission' && ( {item.type === 'content_submission' && (
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<SubmissionMetadataPanel item={item} /> <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} /> <AuditTrailViewer submissionId={item.id} />
</div> </div>
)} )}

View File

@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay'; import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay'; import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay'; import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
import type { TimelineSubmissionData } from '@/types/timeline'; import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler'; import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary'; import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
interface SubmissionItemsListProps { interface SubmissionItemsListProps {
submissionId: string; submissionId: string;
@@ -34,6 +36,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { isCollapsed, toggle } = useDetailedViewState();
useEffect(() => { useEffect(() => {
fetchSubmissionItems(); fetchSubmissionItems();
@@ -188,17 +191,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as ParkSubmissionData} data={entityData as unknown as ParkSubmissionData}
actionType={actionType} actionType={actionType}
/> />
<div className="mt-6 pt-6 border-t"> <DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -211,17 +211,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as RideSubmissionData} data={entityData as unknown as RideSubmissionData}
actionType={actionType} actionType={actionType}
/> />
<div className="mt-6 pt-6 border-t"> <DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -234,17 +231,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as CompanySubmissionData} data={entityData as unknown as CompanySubmissionData}
actionType={actionType} actionType={actionType}
/> />
<div className="mt-6 pt-6 border-t"> <DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -257,17 +251,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as RideModelSubmissionData} data={entityData as unknown as RideModelSubmissionData}
actionType={actionType} actionType={actionType}
/> />
<div className="mt-6 pt-6 border-t"> <DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -280,17 +271,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as TimelineSubmissionData} data={entityData as unknown as TimelineSubmissionData}
actionType={actionType} actionType={actionType}
/> />
<div className="mt-6 pt-6 border-t"> <DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { logger } from '@/lib/logger';
const STORAGE_KEY = 'detailed-view-collapsed';
interface UseDetailedViewStateReturn {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (value: boolean) => void;
}
/**
* Hook to manage detailed view collapsed/expanded state
* Syncs with localStorage for persistence across sessions
* Defaults to collapsed to reduce visual clutter
*/
export function useDetailedViewState(): UseDetailedViewStateReturn {
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
// Initialize from localStorage on mount
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Default to collapsed (true) to reduce visual clutter
return stored ? JSON.parse(stored) : true;
} catch (error) {
logger.warn('Error reading detailed view state from localStorage', { error });
return true;
}
});
// Sync to localStorage when state changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
} catch (error) {
logger.warn('Error saving detailed view state to localStorage', { error });
}
}, [isCollapsed]);
const toggle = () => setIsCollapsed(prev => !prev);
const setCollapsed = (value: boolean) => setIsCollapsed(value);
return {
isCollapsed,
toggle,
setCollapsed,
};
}