mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 21:11:12 -05:00
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { useState, useEffect, memo } from 'react';
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
|
|
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
|
|
import { RichParkDisplay } from './displays/RichParkDisplay';
|
|
import { RichRideDisplay } from './displays/RichRideDisplay';
|
|
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
|
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
|
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import type { SubmissionItemData } from '@/types/submissions';
|
|
import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data';
|
|
import type { TimelineSubmissionData } from '@/types/timeline';
|
|
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
|
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
|
|
|
interface SubmissionItemsListProps {
|
|
submissionId: string;
|
|
view?: 'summary' | 'detailed';
|
|
showImages?: boolean;
|
|
}
|
|
|
|
export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|
submissionId,
|
|
view = 'summary',
|
|
showImages = true
|
|
}: SubmissionItemsListProps) {
|
|
const [items, setItems] = useState<SubmissionItemData[]>([]);
|
|
const [hasPhotos, setHasPhotos] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchSubmissionItems();
|
|
}, [submissionId]);
|
|
|
|
const fetchSubmissionItems = async () => {
|
|
try {
|
|
// Only show skeleton on initial load, show refreshing indicator on refresh
|
|
if (loading) {
|
|
setLoading(true);
|
|
} else {
|
|
setRefreshing(true);
|
|
}
|
|
setError(null);
|
|
|
|
// Use database function to fetch submission items with entity data in one query
|
|
// This eliminates N+1 query problem and properly handles RLS/AAL2 checks
|
|
const { data: itemsData, error: itemsError } = await supabase
|
|
.rpc('get_submission_items_with_entities', {
|
|
p_submission_id: submissionId
|
|
});
|
|
|
|
if (itemsError) throw itemsError;
|
|
|
|
// Transform to expected format with better null handling
|
|
const transformedItems = (itemsData || []).map((item: any) => {
|
|
// Ensure entity_data is at least an empty object, never null
|
|
const safeEntityData = item.entity_data && typeof item.entity_data === 'object'
|
|
? item.entity_data
|
|
: {};
|
|
|
|
return {
|
|
...item,
|
|
item_data: safeEntityData,
|
|
entity_data: item.entity_data // Keep original for debugging
|
|
};
|
|
});
|
|
|
|
// Check for photo submissions (using array query to avoid 406)
|
|
const { data: photoData, error: photoError } = await supabase
|
|
.from('photo_submissions')
|
|
.select('id')
|
|
.eq('submission_id', submissionId);
|
|
|
|
if (photoError) {
|
|
handleNonCriticalError(photoError, {
|
|
action: 'Check photo submissions',
|
|
metadata: { submissionId }
|
|
});
|
|
}
|
|
|
|
setItems(transformedItems as SubmissionItemData[]);
|
|
setHasPhotos(!!(photoData && photoData.length > 0));
|
|
} catch (err) {
|
|
handleNonCriticalError(err, {
|
|
action: 'Fetch submission items',
|
|
metadata: { submissionId }
|
|
});
|
|
setError('Failed to load submission details');
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<Skeleton className="h-16 w-full" />
|
|
{view === 'detailed' && <Skeleton className="h-32 w-full" />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (items.length === 0 && !hasPhotos) {
|
|
return (
|
|
<div className="text-sm text-muted-foreground">
|
|
No items found for this submission
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render item with appropriate display component
|
|
const renderItem = (item: SubmissionItemData) => {
|
|
// SubmissionItemData from submissions.ts has item_data property
|
|
const entityData = item.item_data;
|
|
const actionType = item.action_type || 'create';
|
|
|
|
// Show item metadata (order_index, depends_on, timestamps, test data flag)
|
|
const itemMetadata = (
|
|
<div className="flex flex-wrap items-center gap-2 mb-2 text-xs text-muted-foreground">
|
|
<Badge variant="outline" className="font-mono">
|
|
#{item.order_index ?? 0}
|
|
</Badge>
|
|
|
|
{item.depends_on && (
|
|
<Badge variant="outline" className="text-xs">
|
|
Depends on: {item.depends_on.slice(0, 8)}...
|
|
</Badge>
|
|
)}
|
|
|
|
{(item as any).is_test_data && (
|
|
<Badge variant="outline" className="bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
|
|
Test Data
|
|
</Badge>
|
|
)}
|
|
|
|
{item.created_at && (
|
|
<span className="font-mono">
|
|
Created: {format(new Date(item.created_at), 'MMM d, HH:mm:ss')}
|
|
</span>
|
|
)}
|
|
|
|
{item.updated_at && item.updated_at !== item.created_at && (
|
|
<span className="font-mono">
|
|
Updated: {format(new Date(item.updated_at), 'MMM d, HH:mm:ss')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Use summary view for compact display
|
|
if (view === 'summary') {
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view={view}
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Use rich displays for detailed view - show BOTH rich display AND field-by-field changes
|
|
if (item.item_type === 'park' && entityData) {
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<RichParkDisplay
|
|
data={entityData as unknown as ParkSubmissionData}
|
|
actionType={actionType}
|
|
/>
|
|
<div className="mt-6 pt-6 border-t">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
All Fields (Detailed View)
|
|
</div>
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view="detailed"
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (item.item_type === 'ride' && entityData) {
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<RichRideDisplay
|
|
data={entityData as unknown as RideSubmissionData}
|
|
actionType={actionType}
|
|
/>
|
|
<div className="mt-6 pt-6 border-t">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
All Fields (Detailed View)
|
|
</div>
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view="detailed"
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if ((['manufacturer', 'operator', 'designer', 'property_owner'] as const).some(type => type === item.item_type) && entityData) {
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<RichCompanyDisplay
|
|
data={entityData as unknown as CompanySubmissionData}
|
|
actionType={actionType}
|
|
/>
|
|
<div className="mt-6 pt-6 border-t">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
All Fields (Detailed View)
|
|
</div>
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view="detailed"
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (item.item_type === 'ride_model' && entityData) {
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<RichRideModelDisplay
|
|
data={entityData as unknown as RideModelSubmissionData}
|
|
actionType={actionType}
|
|
/>
|
|
<div className="mt-6 pt-6 border-t">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
All Fields (Detailed View)
|
|
</div>
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view="detailed"
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if ((item.item_type === 'milestone' || item.item_type === 'timeline_event') && entityData) {
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<RichTimelineEventDisplay
|
|
data={entityData as unknown as TimelineSubmissionData}
|
|
actionType={actionType}
|
|
/>
|
|
<div className="mt-6 pt-6 border-t">
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
All Fields (Detailed View)
|
|
</div>
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view="detailed"
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Fallback to SubmissionChangesDisplay
|
|
return (
|
|
<>
|
|
{itemMetadata}
|
|
<SubmissionChangesDisplay
|
|
item={item}
|
|
view={view}
|
|
showImages={showImages}
|
|
submissionId={submissionId}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<ModerationErrorBoundary submissionId={submissionId}>
|
|
<div className={view === 'summary' ? 'flex flex-col gap-3' : 'flex flex-col gap-6'}>
|
|
{refreshing && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
<span>Refreshing...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Show regular submission items */}
|
|
{items.map((item) => (
|
|
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
|
{renderItem(item)}
|
|
</div>
|
|
))}
|
|
|
|
{/* Show photo submission if exists */}
|
|
{hasPhotos && (
|
|
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
|
<PhotoSubmissionDisplay submissionId={submissionId} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ModerationErrorBoundary>
|
|
);
|
|
});
|