feat: Add park operators and owners to form

This commit is contained in:
gpt-engineer-app[bot]
2025-09-30 00:14:24 +00:00
parent 0ddae7493c
commit 28feea6264
4 changed files with 292 additions and 22 deletions

View File

@@ -11,7 +11,11 @@ import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { toast } from '@/hooks/use-toast';
import { MapPin, Save, X } from 'lucide-react';
import { MapPin, Save, X, Plus } from 'lucide-react';
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';
const parkSchema = z.object({
name: z.string().min(1, 'Park name is required'),
@@ -23,15 +27,32 @@ const parkSchema = z.object({
closing_date: z.string().optional(),
website_url: z.string().url().optional().or(z.literal('')),
phone: z.string().optional(),
email: z.string().email().optional().or(z.literal(''))
email: z.string().email().optional().or(z.literal('')),
operator_id: z.string().uuid().optional(),
property_owner_id: z.string().uuid().optional()
});
type ParkFormData = z.infer<typeof parkSchema>;
interface ParkFormProps {
onSubmit: (data: ParkFormData & { banner_image_url?: string; card_image_url?: string; banner_image_id?: string; card_image_id?: string }) => Promise<void>;
onSubmit: (data: ParkFormData & {
banner_image_url?: string;
card_image_url?: string;
banner_image_id?: string;
card_image_id?: string;
operator_id?: string;
property_owner_id?: string;
_compositeSubmission?: any;
}) => Promise<void>;
onCancel?: () => void;
initialData?: Partial<ParkFormData & { banner_image_url?: string; card_image_url?: string; banner_image_id?: string; card_image_id?: string }>;
initialData?: Partial<ParkFormData & {
banner_image_url?: string;
card_image_url?: string;
banner_image_id?: string;
card_image_id?: string;
operator_id?: string;
property_owner_id?: string;
}>;
isEditing?: boolean;
}
@@ -61,6 +82,20 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
const [cardImage, setCardImage] = useState<string>(initialData?.card_image_url || '');
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
const [cardImageId, setCardImageId] = useState<string>(initialData?.card_image_id || '');
// Operator state
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
const [tempNewOperator, setTempNewOperator] = useState<any>(null);
const [isOperatorModalOpen, setIsOperatorModalOpen] = useState(false);
// Property Owner state
const [selectedPropertyOwnerId, setSelectedPropertyOwnerId] = useState<string>(initialData?.property_owner_id || '');
const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState<any>(null);
const [isPropertyOwnerModalOpen, setIsPropertyOwnerModalOpen] = useState(false);
// Fetch data
const { operators, loading: operatorsLoading } = useOperators();
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
// Extract Cloudflare image ID from URL
const extractImageId = (url: string): string => {
@@ -86,7 +121,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
closing_date: initialData?.closing_date || '',
website_url: initialData?.website_url || '',
phone: initialData?.phone || '',
email: initialData?.email || ''
email: initialData?.email || '',
operator_id: initialData?.operator_id || undefined,
property_owner_id: initialData?.property_owner_id || undefined
}
});
@@ -108,12 +145,38 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
const handleFormSubmit = async (data: ParkFormData) => {
setSubmitting(true);
try {
// Build composite submission if new entities were created
const submissionContent: any = {
park: {
...data,
banner_image_url: bannerImage || undefined,
card_image_url: cardImage || undefined,
banner_image_id: bannerImageId || undefined,
card_image_id: cardImageId || undefined
},
};
// Add new operator if created
if (tempNewOperator) {
submissionContent.new_operator = tempNewOperator;
submissionContent.park.operator_id = null;
}
// Add new property owner if created
if (tempNewPropertyOwner) {
submissionContent.new_property_owner = tempNewPropertyOwner;
submissionContent.park.property_owner_id = null;
}
await onSubmit({
...data,
banner_image_url: bannerImage || undefined,
card_image_url: cardImage || undefined,
banner_image_id: bannerImageId || undefined,
card_image_id: cardImageId || undefined
card_image_id: cardImageId || undefined,
operator_id: tempNewOperator ? undefined : (selectedOperatorId || undefined),
property_owner_id: tempNewPropertyOwner ? undefined : (selectedPropertyOwnerId || undefined),
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
});
toast({
@@ -251,6 +314,105 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
</div>
</div>
{/* Operator & Property Owner Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
<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) => {
setValue('operator_id', value);
setSelectedOperatorId(value);
}}
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 */}
<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) => {
setValue('property_owner_id', value);
setSelectedPropertyOwnerId(value);
}}
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">
@@ -373,6 +535,32 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
)}
</div>
</form>
{/* Operator Modal - Placeholder */}
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Operator</DialogTitle>
<DialogDescription>
Add a new park operator company
</DialogDescription>
</DialogHeader>
<p className="text-muted-foreground">Operator form coming soon...</p>
</DialogContent>
</Dialog>
{/* Property Owner Modal - Placeholder */}
<Dialog open={isPropertyOwnerModalOpen} onOpenChange={setIsPropertyOwnerModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Property Owner</DialogTitle>
<DialogDescription>
Add a new park property owner company
</DialogDescription>
</DialogHeader>
<p className="text-muted-foreground">Property owner form coming soon...</p>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);

View File

@@ -200,4 +200,76 @@ export function useCompanyHeadquarters() {
}, []);
return { headquarters, loading };
}
export function useOperators() {
const [operators, setOperators] = useState<ComboboxOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchOperators() {
setLoading(true);
try {
const { data, error } = await supabase
.from('companies')
.select('id, name')
.eq('company_type', 'operator')
.order('name');
if (error) throw error;
setOperators(
(data || []).map(company => ({
label: company.name,
value: company.id
}))
);
} catch (error) {
console.error('Error fetching operators:', error);
setOperators([]);
} finally {
setLoading(false);
}
}
fetchOperators();
}, []);
return { operators, loading };
}
export function usePropertyOwners() {
const [propertyOwners, setPropertyOwners] = useState<ComboboxOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchPropertyOwners() {
setLoading(true);
try {
const { data, error } = await supabase
.from('companies')
.select('id, name')
.eq('company_type', 'property_owner')
.order('name');
if (error) throw error;
setPropertyOwners(
(data || []).map(company => ({
label: company.name,
value: company.id
}))
);
} catch (error) {
console.error('Error fetching property owners:', error);
setPropertyOwners([]);
} finally {
setLoading(false);
}
}
fetchPropertyOwners();
}, []);
return { propertyOwners, loading };
}

View File

@@ -177,23 +177,29 @@ export default function ParkDetail() {
try {
if (isModerator()) {
// Moderators can update directly
const updateData: any = {
name: parkData.name,
slug: parkData.slug,
description: parkData.description || null,
park_type: parkData.park_type,
status: parkData.status,
opening_date: parkData.opening_date || null,
closing_date: parkData.closing_date || null,
website_url: parkData.website_url || null,
phone: parkData.phone || null,
email: parkData.email || null,
banner_image_url: parkData.banner_image_url || null,
banner_image_id: parkData.banner_image_id || null,
card_image_url: parkData.card_image_url || null,
card_image_id: parkData.card_image_id || null,
operator_id: parkData.operator_id || null,
property_owner_id: parkData.property_owner_id || null
};
const { error } = await supabase
.from('parks')
.update({
name: parkData.name,
slug: parkData.slug,
description: parkData.description,
park_type: parkData.park_type,
status: parkData.status,
opening_date: parkData.opening_date || null,
closing_date: parkData.closing_date || null,
website_url: parkData.website_url || null,
phone: parkData.phone || null,
email: parkData.email || null,
banner_image_url: parkData.banner_image_url || null,
banner_image_id: parkData.banner_image_id || null,
card_image_url: parkData.card_image_url || null,
card_image_id: parkData.card_image_id || null,
...updateData,
updated_at: new Date().toISOString()
})
.eq('id', park.id);
@@ -663,7 +669,9 @@ export default function ParkDetail() {
banner_image_url: park?.banner_image_url,
banner_image_id: park?.banner_image_id,
card_image_url: park?.card_image_url,
card_image_id: park?.card_image_id
card_image_id: park?.card_image_id,
operator_id: park?.operator?.id,
property_owner_id: park?.property_owner?.id
}}
isEditing={true}
/>

View File

@@ -268,7 +268,9 @@ export default function Parks() {
banner_image_url: parkData.banner_image_url || null,
banner_image_id: parkData.banner_image_id || null,
card_image_url: parkData.card_image_url || null,
card_image_id: parkData.card_image_id || null
card_image_id: parkData.card_image_id || null,
operator_id: parkData.operator_id || null,
property_owner_id: parkData.property_owner_id || null
});
if (error) throw error;