Compare commits

..

6 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
bf4b05bb18 Changes 2025-11-12 14:42:20 +00:00
gpt-engineer-app[bot]
6bd7d24a1b Add item-level history badge and animations
- Show a dynamic field-count badge next to All Fields (Detailed View) in the moderation queue
- Animate collapsible sections with smooth transitions for expand/collapse
- Pass fieldCount to DetailedViewCollapsible and render count alongside header; add animation utility in DetailedViewCollapsible.tsx
- Ensure SubmissionItemsList passes item data to calculate field counts and display badges accordingly

X-Lovable-Edit-ID: edt-ffd226b0-af99-491b-b6b8-3fe0063e0082
2025-11-12 14:40:52 +00:00
gpt-engineer-app[bot]
72e76e86af testing changes with virtual file cleanup 2025-11-12 14:40:51 +00:00
gpt-engineer-app[bot]
a35486fb11 Add collapsible detailed view
Implements expand/collapse for the All Fields (Detailed View) sections in the moderation queue:
- Adds useDetailedViewState hook to persist collapse state in localStorage (default collapsed)
- Adds DetailedViewCollapsible wrapper component using Radix Collapsible
- Updates SubmissionItemsList to wrap each detailed view block with the new collapsible, and imports the new hook and component

X-Lovable-Edit-ID: edt-a95a840d-e7e7-4f9e-aa25-03bb68194aee
2025-11-12 14:36:50 +00:00
gpt-engineer-app[bot]
3d3ae57ee3 testing changes with virtual file cleanup 2025-11-12 14:36:49 +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
6 changed files with 322 additions and 34 deletions

View File

@@ -0,0 +1,67 @@
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface DetailedViewCollapsibleProps {
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
fieldCount?: number;
className?: string;
}
/**
* Collapsible wrapper for detailed field-by-field view sections
* Provides expand/collapse functionality with visual indicators
*/
export function DetailedViewCollapsible({
isCollapsed,
onToggle,
children,
fieldCount,
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 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
All Fields (Detailed View)
</span>
{fieldCount !== undefined && fieldCount > 0 && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs font-normal transition-transform duration-200 hover:scale-105"
>
{fieldCount}
</Badge>
)}
</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 transition-transform duration-200" />
) : (
<ChevronUp className="h-4 w-4 text-muted-foreground transition-transform duration-200" />
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
{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

@@ -1,4 +1,4 @@
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar } from 'lucide-react'; import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar, Maximize2, Minimize2 } from 'lucide-react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -7,6 +7,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { RefreshButton } from '@/components/ui/refresh-button'; import { RefreshButton } from '@/components/ui/refresh-button';
import { QueueSortControls } from './QueueSortControls'; import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState'; import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker'; import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation'; import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
@@ -55,6 +56,7 @@ export const QueueFilters = ({
isRefreshing = false isRefreshing = false
}: QueueFiltersProps) => { }: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState(); const { isCollapsed, toggle } = useFilterPanelState();
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
// Count active filters // Count active filters
const activeFilterCount = [ const activeFilterCount = [
@@ -76,14 +78,36 @@ export const QueueFilters = ({
</Badge> </Badge>
)} )}
</div> </div>
{isMobile && ( <div className="flex items-center gap-2">
<CollapsibleTrigger asChild> {/* Global toggle for detailed views */}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} /> variant="ghost"
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span> size="sm"
</Button> onClick={toggleDetails}
</CollapsibleTrigger> className="h-8 gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
)} title={detailsCollapsed ? "Expand all detailed views" : "Collapse all detailed views"}
>
{detailsCollapsed ? (
<>
<Maximize2 className="h-3.5 w-3.5" />
{!isMobile && <span>Expand All</span>}
</>
) : (
<>
<Minimize2 className="h-3.5 w-3.5" />
{!isMobile && <span>Collapse All</span>}
</>
)}
</Button>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
</div>
</div> </div>
<CollapsibleContent className="space-y-4"> <CollapsibleContent className="space-y-4">

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,11 +36,18 @@ 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();
}, [submissionId]); }, [submissionId]);
// Helper function to count non-null fields in entity data
const countFields = (data: any): number => {
if (!data || typeof data !== 'object') return 0;
return Object.values(data).filter(value => value !== null && value !== undefined).length;
};
const fetchSubmissionItems = async () => { const fetchSubmissionItems = async () => {
try { try {
// Only show skeleton on initial load, show refreshing indicator on refresh // Only show skeleton on initial load, show refreshing indicator on refresh
@@ -188,17 +197,18 @@ 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
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3"> isCollapsed={isCollapsed}
All Fields (Detailed View) onToggle={toggle}
</div> fieldCount={countFields(entityData)}
>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -211,17 +221,18 @@ 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
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3"> isCollapsed={isCollapsed}
All Fields (Detailed View) onToggle={toggle}
</div> fieldCount={countFields(entityData)}
>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -234,17 +245,18 @@ 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
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3"> isCollapsed={isCollapsed}
All Fields (Detailed View) onToggle={toggle}
</div> fieldCount={countFields(entityData)}
>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -257,17 +269,18 @@ 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
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3"> isCollapsed={isCollapsed}
All Fields (Detailed View) onToggle={toggle}
</div> fieldCount={countFields(entityData)}
>
<SubmissionChangesDisplay <SubmissionChangesDisplay
item={item} item={item}
view="detailed" view="detailed"
showImages={showImages} showImages={showImages}
submissionId={submissionId} submissionId={submissionId}
/> />
</div> </DetailedViewCollapsible>
</> </>
); );
} }
@@ -280,17 +293,18 @@ 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
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3"> isCollapsed={isCollapsed}
All Fields (Detailed View) onToggle={toggle}
</div> fieldCount={countFields(entityData)}
>
<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,
};
}