diff --git a/src/components/moderation/TimelineEventPreview.tsx b/src/components/moderation/TimelineEventPreview.tsx new file mode 100644 index 00000000..582a4fec --- /dev/null +++ b/src/components/moderation/TimelineEventPreview.tsx @@ -0,0 +1,60 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Calendar, Tag } from 'lucide-react'; +import type { TimelineSubmissionData } from '@/types/timeline'; + +interface TimelineEventPreviewProps { + data: TimelineSubmissionData; +} + +export function TimelineEventPreview({ data }: TimelineEventPreviewProps) { + const formatEventType = (type: string) => { + return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + return ( + + + + + Timeline Event: {data.title} + + + +
+
+ Event Type: +

+ {formatEventType(data.event_type)} +

+
+
+ Date: +

+ + {new Date(data.event_date).toLocaleDateString()} + ({data.event_date_precision}) +

+
+
+ + {(data.from_value || data.to_value) && ( +
+ Change: + + {data.from_value || '—'} → {data.to_value || '—'} + +
+ )} + + {data.description && ( +
+ Description: +

+ {data.description} +

+
+ )} +
+
+ ); +} diff --git a/src/components/timeline/EntityTimelineManager.tsx b/src/components/timeline/EntityTimelineManager.tsx new file mode 100644 index 00000000..a00edfe1 --- /dev/null +++ b/src/components/timeline/EntityTimelineManager.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { TimelineEventEditorDialog } from './TimelineEventEditorDialog'; +import { EntityHistoryTimeline } from '@/components/history/EntityHistoryTimeline'; +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { EntityType, TimelineEvent } from '@/types/timeline'; + +interface EntityTimelineManagerProps { + entityType: EntityType; + entityId: string; + entityName: string; +} + +export function EntityTimelineManager({ + entityType, + entityId, + entityName, +}: EntityTimelineManagerProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // Fetch timeline events + const { data: events, refetch } = useQuery({ + queryKey: ['timeline-events', entityType, entityId], + queryFn: async () => { + const { data, error } = await supabase + .from('entity_timeline_events') + .select('*') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .eq('is_public', true) + .not('approved_by', 'is', null) + .order('event_date', { ascending: false }); + + if (error) throw error; + return data as TimelineEvent[]; + }, + }); + + // Convert to HistoryEvent format for display + const historyEvents = events?.map((event) => { + // Map timeline event types to history event types + let mappedType: 'name_change' | 'status_change' | 'ownership_change' | 'relocation' | 'milestone' = 'milestone'; + + if (event.event_type === 'name_change') mappedType = 'name_change'; + else if (event.event_type === 'status_change') mappedType = 'status_change'; + else if (event.event_type === 'operator_change' || event.event_type === 'owner_change') mappedType = 'ownership_change'; + else if (event.event_type === 'location_change') mappedType = 'relocation'; + + return { + date: event.event_date, + title: event.title, + description: event.description, + type: mappedType, + from: event.from_value, + to: event.to_value, + }; + }) || []; + + return ( + + +
+
+ Timeline & History + + Historical events and milestones for {entityName} + +
+ +
+
+ + + + + refetch()} + /> +
+ ); +} diff --git a/src/components/timeline/TimelineEventEditorDialog.tsx b/src/components/timeline/TimelineEventEditorDialog.tsx new file mode 100644 index 00000000..d63cef60 --- /dev/null +++ b/src/components/timeline/TimelineEventEditorDialog.tsx @@ -0,0 +1,386 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Switch } from '@/components/ui/switch'; +import { Loader2 } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/use-toast'; +import { submitTimelineEvent, submitTimelineEventUpdate } from '@/lib/entitySubmissionHelpers'; +import type { + EntityType, + TimelineEventFormData, +} from '@/types/timeline'; + +// Validation schema +const timelineEventSchema = z.object({ + event_type: z.enum([ + 'name_change', + 'operator_change', + 'owner_change', + 'location_change', + 'status_change', + 'closure', + 'reopening', + 'renovation', + 'expansion', + 'acquisition', + 'milestone', + 'other', + ]), + event_date: z.date({ + message: 'Event date is required', + }), + event_date_precision: z.enum(['day', 'month', 'year']).default('day'), + title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), + description: z.string().max(1000, 'Description is too long').optional(), + + // Conditional fields + from_value: z.string().max(200).optional(), + to_value: z.string().max(200).optional(), + from_entity_id: z.string().uuid().optional(), + to_entity_id: z.string().uuid().optional(), + from_location_id: z.string().uuid().optional(), + to_location_id: z.string().uuid().optional(), + + is_public: z.boolean().default(true), +}); + +interface TimelineEventEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + entityType: EntityType; + entityId: string; + entityName: string; + existingEvent?: any; + onSuccess?: () => void; +} + +export function TimelineEventEditorDialog({ + open, + onOpenChange, + entityType, + entityId, + entityName, + existingEvent, + onSuccess, +}: TimelineEventEditorDialogProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isEditing = !!existingEvent; + + const form = useForm({ + resolver: zodResolver(timelineEventSchema), + defaultValues: existingEvent ? { + event_type: existingEvent.event_type, + event_date: new Date(existingEvent.event_date), + event_date_precision: existingEvent.event_date_precision, + title: existingEvent.title, + description: existingEvent.description || '', + from_value: existingEvent.from_value || '', + to_value: existingEvent.to_value || '', + from_entity_id: existingEvent.from_entity_id || '', + to_entity_id: existingEvent.to_entity_id || '', + from_location_id: existingEvent.from_location_id || '', + to_location_id: existingEvent.to_location_id || '', + is_public: existingEvent.is_public, + } : { + event_type: 'milestone', + event_date: new Date(), + event_date_precision: 'day', + title: '', + description: '', + is_public: true, + }, + }); + + const selectedEventType = form.watch('event_type'); + + const onSubmit = async (data: TimelineEventFormData) => { + if (!user) { + toast({ + title: 'Authentication required', + description: 'You must be logged in to submit timeline events.', + variant: 'destructive', + }); + return; + } + + setIsSubmitting(true); + try { + if (isEditing) { + await submitTimelineEventUpdate(existingEvent.id, data, user.id); + toast({ + title: 'Timeline event update submitted', + description: 'Your update has been submitted for moderator review.', + }); + } else { + await submitTimelineEvent(entityType, entityId, data, user.id); + toast({ + title: 'Timeline event submitted', + description: 'Your timeline event has been submitted for moderator review.', + }); + } + + form.reset(); + onOpenChange(false); + onSuccess?.(); + } catch (error) { + console.error('Failed to submit timeline event:', error); + toast({ + title: 'Submission failed', + description: error instanceof Error ? error.message : 'Failed to submit timeline event', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + // Event type configurations for conditional field rendering + const showFromTo = [ + 'name_change', + 'operator_change', + 'owner_change', + 'status_change', + ].includes(selectedEventType); + + return ( + + + + + {isEditing ? 'Edit' : 'Add'} Timeline Event + + + Add a historical milestone or event for {entityName}. + All submissions go through moderator review. + + + +
+ + ( + + Event Type + + + + )} + /> + + ( + + Event Title + + + + + A brief, descriptive title for this event + + + + )} + /> + +
+ ( + + Event Date + + + + )} + /> + + ( + + Date Precision + + + + )} + /> +
+ + {showFromTo && ( +
+ ( + + From + + + + + + )} + /> + + ( + + To + + + + + + )} + /> +
+ )} + + ( + + Description (Optional) + +