Files
thrilltrack-explorer/src/components/admin/ParkForm.tsx
gpt-engineer-app[bot] d0c613031e Migrate date precision to exact
Batch update all date precision handling to use expanded DatePrecision, replace hardcoded day defaults, and adjust related validation, UI, and helpers. Includes wrapper migration across Phase 1-3 functions, updates to logs, displays, and formatting utilities to align frontend with new precision values ('exact', 'month', 'year', 'decade', 'century', 'approximate').
2025-11-11 22:05:29 +00:00

740 lines
28 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas, validateRequiredFields } from '@/lib/entityValidationSchemas';
import { validateSubmissionHandler } from '@/lib/entityFormValidation';
import { getErrorMessage } from '@/lib/errorHandler';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
import { SlugField } from '@/components/ui/slug-field';
import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler';
import { MapPin, Save, X, Plus, AlertCircle } from 'lucide-react';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import { Badge } from '@/components/ui/badge';
import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import type { TempCompanyData } from '@/types/company';
import { LocationSearch } from './LocationSearch';
import { OperatorForm } from './OperatorForm';
import { PropertyOwnerForm } from './PropertyOwnerForm';
import { Checkbox } from '@/components/ui/checkbox';
const parkSchema = z.object({
name: z.string().min(1, 'Park name is required'),
slug: z.string().min(1, 'Slug is required'), // Auto-generated, validated on submit
description: z.string().optional(),
park_type: z.string().min(1, 'Park type is required'),
status: z.string().min(1, 'Status is required'),
opening_date: z.string().optional().transform(val => val || undefined),
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
closing_date: z.string().optional().transform(val => val || undefined),
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
location: z.object({
name: z.string(),
street_address: z.string().optional(),
city: z.string().optional(),
state_province: z.string().optional(),
country: z.string(),
postal_code: z.string().optional(),
latitude: z.number(),
longitude: z.number(),
timezone: z.string().optional(),
display_name: z.string(),
}).optional(),
location_id: z.string().uuid().optional(),
website_url: z.string().url().optional().or(z.literal('')),
phone: z.string().optional(),
email: z.string().email().optional().or(z.literal('')),
operator_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
property_owner_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
source_url: z.string().url().optional().or(z.literal('')),
submission_notes: z.string().max(1000).optional().or(z.literal('')),
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string().optional(),
file: z.instanceof(File).optional(),
isLocal: z.boolean().optional(),
caption: z.string().optional(),
})),
banner_assignment: z.number().nullable().optional(),
card_assignment: z.number().nullable().optional(),
}).optional()
});
type ParkFormData = z.infer<typeof parkSchema>;
interface ParkFormProps {
onSubmit: (data: ParkFormData & {
operator_id?: string;
property_owner_id?: string;
_compositeSubmission?: import('@/types/composite-submission').ParkCompositeSubmission;
}) => Promise<void>;
onCancel?: () => void;
initialData?: Partial<ParkFormData & {
id?: string;
operator_id?: string;
property_owner_id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
isEditing?: boolean;
}
const parkTypes = [
{ value: 'theme_park', label: 'Theme Park' },
{ value: 'amusement_park', label: 'Amusement Park' },
{ value: 'water_park', label: 'Water Park' },
{ value: 'family_entertainment', label: 'Family Entertainment Center' },
{ value: 'adventure_park', label: 'Adventure Park' },
{ value: 'safari_park', label: 'Safari Park' },
{ value: 'carnival', label: 'Carnival' },
{ value: 'fair', label: 'Fair' }
];
const statusOptions = [
'Operating',
'Closed Temporarily',
'Closed Permanently',
'Under Construction',
'Planned',
'Abandoned'
];
// Status mappings
const STATUS_DISPLAY_TO_DB: Record<string, string> = {
'Operating': 'operating',
'Closed Temporarily': 'closed_temporarily',
'Closed Permanently': 'closed_permanently',
'Under Construction': 'under_construction',
'Planned': 'planned',
'Abandoned': 'abandoned'
};
const STATUS_DB_TO_DISPLAY: Record<string, string> = {
'operating': 'Operating',
'closed_temporarily': 'Closed Temporarily',
'closed_permanently': 'Closed Permanently',
'under_construction': 'Under Construction',
'planned': 'Planned',
'abandoned': 'Abandoned'
};
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
const { isModerator } = useUserRole();
// Validate that onSubmit uses submission helpers (dev mode only)
useEffect(() => {
validateSubmissionHandler(onSubmit, 'park');
}, [onSubmit]);
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
// Operator state
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
const [tempNewOperator, setTempNewOperator] = useState<TempCompanyData | null>(null);
const [isOperatorModalOpen, setIsOperatorModalOpen] = useState(false);
// Property Owner state
const [selectedPropertyOwnerId, setSelectedPropertyOwnerId] = useState<string>(initialData?.property_owner_id || '');
const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState<TempCompanyData | null>(null);
const [isPropertyOwnerModalOpen, setIsPropertyOwnerModalOpen] = useState(false);
// Operator is Owner checkbox state
const [operatorIsOwner, setOperatorIsOwner] = useState<boolean>(
!!(initialData?.operator_id && initialData?.property_owner_id &&
initialData?.operator_id === initialData?.property_owner_id)
);
// Fetch data
const { operators, loading: operatorsLoading } = useOperators();
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
const {
register,
handleSubmit,
setValue,
watch,
trigger,
formState: { errors }
} = useForm<ParkFormData>({
resolver: zodResolver(entitySchemas.park),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
description: initialData?.description || '',
park_type: initialData?.park_type || '',
status: initialData?.status || 'operating' as const, // Store DB value
opening_date: initialData?.opening_date || undefined,
closing_date: initialData?.closing_date || undefined,
location_id: initialData?.location_id || undefined,
website_url: initialData?.website_url || '',
phone: initialData?.phone || '',
email: initialData?.email || '',
operator_id: initialData?.operator_id || undefined,
property_owner_id: initialData?.property_owner_id || undefined,
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: { uploaded: [] }
}
});
// Sync property owner with operator when checkbox is enabled
useEffect(() => {
if (operatorIsOwner && selectedOperatorId) {
setSelectedPropertyOwnerId(selectedOperatorId);
setValue('property_owner_id', selectedOperatorId);
}
}, [operatorIsOwner, selectedOperatorId, setValue]);
const handleFormSubmit = async (data: ParkFormData) => {
setIsSubmitting(true);
try {
// Pre-submission validation for required fields
const { valid, errors: validationErrors } = validateRequiredFields('park', data);
if (!valid) {
validationErrors.forEach(error => {
toast({
variant: 'destructive',
title: 'Missing Required Fields',
description: error
});
});
setIsSubmitting(false);
return;
}
// CRITICAL: Block new photo uploads on edits
if (isEditing && data.images?.uploaded) {
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
if (hasNewPhotos) {
toast({
variant: 'destructive',
title: 'Validation Error',
description: 'New photos cannot be added during edits. Please remove new photos or use the photo gallery.'
});
return;
}
}
// Build composite submission if new entities were created
const submissionContent: import('@/types/composite-submission').ParkCompositeSubmission = {
park: data,
};
// Add new operator if created
if (tempNewOperator) {
submissionContent.new_operator = tempNewOperator;
submissionContent.park.operator_id = null;
// If operator is also owner, use same entity for both
if (operatorIsOwner) {
submissionContent.new_property_owner = tempNewOperator;
submissionContent.park.property_owner_id = null;
}
}
// Add new property owner if created (and not already set above)
if (tempNewPropertyOwner && !operatorIsOwner) {
submissionContent.new_property_owner = tempNewPropertyOwner;
submissionContent.park.property_owner_id = null;
}
// Determine final IDs to pass
// When creating new entities via composite submission, IDs should be undefined
// When using existing entities, pass their IDs directly
let finalOperatorId: string | undefined;
let finalPropertyOwnerId: string | undefined;
if (tempNewOperator) {
// New operator being created via composite submission
finalOperatorId = undefined;
finalPropertyOwnerId = operatorIsOwner ? undefined :
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
} else {
// Using existing operator
finalOperatorId = selectedOperatorId || undefined;
finalPropertyOwnerId = operatorIsOwner ? finalOperatorId :
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
}
// Debug: Log what's being submitted
const submissionData = {
...data,
operator_id: finalOperatorId,
property_owner_id: finalPropertyOwnerId,
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
};
console.info('[ParkForm] Submitting park data:', {
hasLocation: !!submissionData.location,
hasLocationId: !!submissionData.location_id,
locationData: submissionData.location,
parkName: submissionData.name,
isEditing
});
await onSubmit(submissionData);
// Parent component handles success feedback
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
handleError(error, {
action: isEditing ? 'Update Park' : 'Create Park',
userId: user?.id,
metadata: {
parkName: data.name,
hasLocation: !!data.location_id,
hasNewOperator: !!tempNewOperator,
hasNewOwner: !!tempNewPropertyOwner
}
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
{isEditing ? 'Edit Park' : 'Create New Park'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Park Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter park name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the park..."
rows={4}
/>
</div>
{/* Park Type and Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Park Type *</Label>
<Select onValueChange={(value) => setValue('park_type', value)} defaultValue={initialData?.park_type}>
<SelectTrigger>
<SelectValue placeholder="Select park type" />
</SelectTrigger>
<SelectContent>
{parkTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.park_type && (
<p className="text-sm text-destructive">{errors.park_type.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Status *</Label>
<Select
onValueChange={(value) => setValue('status', value)}
defaultValue={initialData?.status || 'operating'}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((displayStatus) => {
const dbValue = STATUS_DISPLAY_TO_DB[displayStatus];
return (
<SelectItem key={dbValue} value={dbValue}>
{displayStatus}
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.status && (
<p className="text-sm text-destructive">{errors.status.message}</p>
)}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FlexibleDateInput
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
onChange={(date, precision) => {
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
setValue('opening_date_precision', precision);
}}
label="Opening Date"
placeholder="Select opening date"
disableFuture={true}
fromYear={1800}
/>
<FlexibleDateInput
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
onChange={(date, precision) => {
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
setValue('closing_date_precision', precision);
}}
label="Closing Date (if applicable)"
placeholder="Select closing date"
disablePast={false}
fromYear={1800}
/>
</div>
{/* Location */}
<div className="space-y-2">
<Label className="flex items-center gap-1">
Location
<span className="text-destructive">*</span>
</Label>
<LocationSearch
onLocationSelect={(location) => {
console.info('[ParkForm] Location selected:', location);
setValue('location', location);
console.info('[ParkForm] Location set in form:', watch('location'));
// Manually trigger validation for the location field
trigger('location');
}}
initialLocationId={watch('location_id')}
/>
{errors.location && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{errors.location.message}
</p>
)}
{!errors.location && (
<p className="text-sm text-muted-foreground">
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
</p>
)}
</div>
{/* Operator & Property Owner Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
<div className="flex items-center space-x-2 mb-4">
<Checkbox
id="operator-is-owner"
checked={operatorIsOwner}
onCheckedChange={(checked) => {
setOperatorIsOwner(checked as boolean);
if (checked && selectedOperatorId) {
setSelectedPropertyOwnerId(selectedOperatorId);
setValue('property_owner_id', selectedOperatorId);
setTempNewPropertyOwner(null);
}
}}
/>
<Label htmlFor="operator-is-owner" className="text-sm font-normal cursor-pointer">
Operator is also the property owner
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Operator Column */}
<div className="space-y-2">
<Label>Park Operator</Label>
{tempNewOperator ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-blue-50 dark:bg-blue-950">
<Badge variant="secondary">New</Badge>
<span className="font-medium">{tempNewOperator.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setTempNewOperator(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Combobox
options={operators}
value={watch('operator_id') || ''}
onValueChange={(value) => {
const cleanValue = value || undefined;
setValue('operator_id', cleanValue);
setSelectedOperatorId(cleanValue || '');
}}
placeholder="Select operator"
searchPlaceholder="Search operators..."
emptyText="No operators found"
loading={operatorsLoading}
/>
)}
{!tempNewOperator && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsOperatorModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create New Operator
</Button>
)}
</div>
{/* Property Owner Column */}
{!operatorIsOwner && (
<div className="space-y-2">
<Label>Property Owner</Label>
{tempNewPropertyOwner ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-green-50 dark:bg-green-950">
<Badge variant="secondary">New</Badge>
<span className="font-medium">{tempNewPropertyOwner.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setTempNewPropertyOwner(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Combobox
options={propertyOwners}
value={watch('property_owner_id') || ''}
onValueChange={(value) => {
const cleanValue = value || undefined;
setValue('property_owner_id', cleanValue);
setSelectedPropertyOwnerId(cleanValue || '');
}}
placeholder="Select property owner"
searchPlaceholder="Search property owners..."
emptyText="No property owners found"
loading={ownersLoading}
/>
)}
{!tempNewPropertyOwner && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsPropertyOwnerModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create New Property Owner
</Button>
)}
</div>
)}
</div>
</div>
{/* Contact Information */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://..."
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
id="phone"
{...register('phone')}
placeholder="+1 (555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="contact@park.com"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via phone call with park', 'Soft opening date not yet announced')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={isEditing ? 'edit' : 'create'}
value={watch('images') as ImageAssignments}
onChange={(images: ImageAssignments) => setValue('images', images)}
entityType="park"
entityId={isEditing ? initialData?.id : undefined}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Form Actions */}
<div className="flex gap-4 pt-6">
<Button
type="submit"
className="flex-1"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{isEditing ? 'Update Park' : 'Create Park'}
</Button>
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
)}
</div>
</form>
{/* Operator Modal */}
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<OperatorForm
initialData={tempNewOperator || undefined}
onSubmit={(data) => {
setTempNewOperator(data);
setIsOperatorModalOpen(false);
setValue('operator_id', 'temp-operator');
}}
onCancel={() => setIsOperatorModalOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Property Owner Modal */}
<Dialog open={isPropertyOwnerModalOpen} onOpenChange={setIsPropertyOwnerModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<PropertyOwnerForm
initialData={tempNewPropertyOwner || undefined}
onSubmit={(data) => {
setTempNewPropertyOwner(data);
setIsPropertyOwnerModalOpen(false);
setValue('property_owner_id', 'temp-property-owner');
}}
onCancel={() => setIsPropertyOwnerModalOpen(false)}
/>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}