629 lines
25 KiB
TypeScript
629 lines
25 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 { Button } from '@/components/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Species } from '@/services/adminApi';
|
|
|
|
// Validation schema
|
|
const speciesSchema = z.object({
|
|
scientific_name: z.string().min(1, 'Scientific name is required'),
|
|
common_name: z.string().min(1, 'Common name is required'),
|
|
kingdom: z.string().min(1, 'Kingdom is required'),
|
|
phylum: z.string().min(1, 'Phylum is required'),
|
|
class: z.string().min(1, 'Class is required'),
|
|
order_name: z.string().min(1, 'Order is required'),
|
|
family: z.string().min(1, 'Family is required'),
|
|
genus: z.string().min(1, 'Genus is required'),
|
|
species_name: z.string().min(1, 'Species is required'),
|
|
authority: z.string(), // No longer optional, allows empty string
|
|
sis_id: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().int().positive().optional()),
|
|
inaturalist_id: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().int().positive().optional()),
|
|
default_image_url: z.string().url().optional().or(z.string().length(0)),
|
|
description: z.string().optional(),
|
|
habitat_description: z.string().optional(),
|
|
population_trend: z.string().optional(),
|
|
population_size: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().optional()),
|
|
generation_length: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().optional()),
|
|
movement_patterns: z.string().optional(),
|
|
use_and_trade: z.string().optional(),
|
|
threats_overview: z.string().optional(),
|
|
conservation_overview: z.string().optional(),
|
|
});
|
|
|
|
interface SpeciesFormProps {
|
|
initialData?: Species;
|
|
onSubmit: (data: Species) => void;
|
|
isSubmitting: boolean;
|
|
}
|
|
|
|
export function SpeciesForm({ initialData, onSubmit, isSubmitting }: SpeciesFormProps) {
|
|
// Start with the description tab if the species has a description
|
|
const initialTab = initialData?.description ? 'description' : 'basic';
|
|
const [activeTab, setActiveTab] = useState(initialTab);
|
|
|
|
// Prepare default values, converting nulls from initialData to empty strings
|
|
// for optional fields to align with Zod's expectation (string | undefined)
|
|
const getSafeDefaultValue = (value: string | null | undefined) => value === null ? '' : value;
|
|
const getSafeNumericDefaultValue = (value: number | string | null | undefined) => {
|
|
if (value === null || value === undefined || value === '') {
|
|
return undefined;
|
|
}
|
|
const num = Number(value);
|
|
return isNaN(num) ? undefined : num;
|
|
};
|
|
|
|
const form = useForm<z.infer<typeof speciesSchema>>({
|
|
resolver: zodResolver(speciesSchema),
|
|
defaultValues: initialData
|
|
? {
|
|
...initialData,
|
|
authority: getSafeDefaultValue(initialData.authority),
|
|
default_image_url: getSafeDefaultValue(initialData.default_image_url),
|
|
description: getSafeDefaultValue(initialData.description),
|
|
habitat_description: getSafeDefaultValue(initialData.habitat_description),
|
|
population_trend: getSafeDefaultValue(initialData.population_trend),
|
|
// For numeric fields, ensure they are numbers or undefined
|
|
sis_id: getSafeNumericDefaultValue(initialData.sis_id),
|
|
inaturalist_id: getSafeNumericDefaultValue(initialData.inaturalist_id),
|
|
population_size: getSafeNumericDefaultValue(initialData.population_size),
|
|
generation_length: getSafeNumericDefaultValue(initialData.generation_length),
|
|
movement_patterns: getSafeDefaultValue(initialData.movement_patterns),
|
|
use_and_trade: getSafeDefaultValue(initialData.use_and_trade),
|
|
threats_overview: getSafeDefaultValue(initialData.threats_overview),
|
|
conservation_overview: getSafeDefaultValue(initialData.conservation_overview),
|
|
}
|
|
: {
|
|
scientific_name: '',
|
|
common_name: '',
|
|
kingdom: 'Animalia',
|
|
phylum: '',
|
|
class: '',
|
|
order_name: '',
|
|
family: '',
|
|
genus: '',
|
|
species_name: '',
|
|
authority: '',
|
|
sis_id: undefined,
|
|
inaturalist_id: undefined,
|
|
default_image_url: '',
|
|
description: '',
|
|
habitat_description: '',
|
|
population_trend: '', // Default to empty string, Select handles it
|
|
population_size: undefined,
|
|
generation_length: undefined,
|
|
movement_patterns: '',
|
|
use_and_trade: '',
|
|
threats_overview: '',
|
|
conservation_overview: '',
|
|
},
|
|
});
|
|
|
|
// If initialData changes (e.g., after a fetch), reset the form with new processed defaults.
|
|
// This is important if the component re-renders with new initialData after the first mount.
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
form.reset({
|
|
...initialData,
|
|
authority: getSafeDefaultValue(initialData.authority),
|
|
default_image_url: getSafeDefaultValue(initialData.default_image_url),
|
|
description: getSafeDefaultValue(initialData.description),
|
|
habitat_description: getSafeDefaultValue(initialData.habitat_description),
|
|
population_trend: getSafeDefaultValue(initialData.population_trend),
|
|
sis_id: getSafeNumericDefaultValue(initialData.sis_id),
|
|
inaturalist_id: getSafeNumericDefaultValue(initialData.inaturalist_id),
|
|
population_size: getSafeNumericDefaultValue(initialData.population_size),
|
|
generation_length: getSafeNumericDefaultValue(initialData.generation_length),
|
|
movement_patterns: getSafeDefaultValue(initialData.movement_patterns),
|
|
use_and_trade: getSafeDefaultValue(initialData.use_and_trade),
|
|
threats_overview: getSafeDefaultValue(initialData.threats_overview),
|
|
conservation_overview: getSafeDefaultValue(initialData.conservation_overview),
|
|
});
|
|
}
|
|
}, [initialData, form.reset]); // form.reset is a stable function reference
|
|
|
|
const handleSubmit = (values: z.infer<typeof speciesSchema>) => {
|
|
// Process values to ensure optional empty strings are sent as empty strings,
|
|
// or convert to null if your backend prefers null for empty optional text fields.
|
|
// The current Zod schema and Supabase text fields generally handle empty strings fine.
|
|
const processedValues = {
|
|
...values,
|
|
description: values.description || '',
|
|
habitat_description: values.habitat_description || '',
|
|
population_trend: values.population_trend || '', // Keep as empty string if that's what select gives
|
|
movement_patterns: values.movement_patterns || '',
|
|
use_and_trade: values.use_and_trade || '',
|
|
threats_overview: values.threats_overview || '',
|
|
conservation_overview: values.conservation_overview || '',
|
|
// Ensure numeric fields are sent as null if undefined, or their numeric value
|
|
sis_id: values.sis_id === undefined ? null : values.sis_id,
|
|
inaturalist_id: values.inaturalist_id === undefined ? null : values.inaturalist_id,
|
|
population_size: values.population_size === undefined ? null : values.population_size,
|
|
generation_length: values.generation_length === undefined ? null : values.generation_length,
|
|
};
|
|
onSubmit(processedValues as Species);
|
|
};
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
|
|
<TabsTrigger value="basic">Basic Info</TabsTrigger>
|
|
<TabsTrigger value="taxonomy">Taxonomy</TabsTrigger>
|
|
<TabsTrigger value="description">Description</TabsTrigger>
|
|
<TabsTrigger value="conservation">Conservation</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Basic Info Tab */}
|
|
<TabsContent value="basic" className="space-y-4 pt-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="scientific_name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Scientific Name*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Ursus maritimus" />
|
|
</FormControl>
|
|
<FormDescription>
|
|
Full scientific name with genus and species
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="common_name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Common Name*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Polar Bear" />
|
|
</FormControl>
|
|
<FormDescription>
|
|
Primary English common name
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="sis_id"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>IUCN SIS ID</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
{...field}
|
|
value={field.value || ''}
|
|
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
|
|
placeholder="e.g. 22823"
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Species Information Service ID from IUCN
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="inaturalist_id"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>iNaturalist ID</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
{...field}
|
|
value={field.value || ''}
|
|
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
|
|
placeholder="e.g. 42021"
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Taxon ID from iNaturalist
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="default_image_url"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Default Image URL</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="https://example.com/image.jpg" value={field.value || ''} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
URL for the main species image
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</TabsContent>
|
|
|
|
{/* Taxonomy Tab */}
|
|
<TabsContent value="taxonomy" className="space-y-4 pt-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="kingdom"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Kingdom*</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select kingdom" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="Animalia">Animalia</SelectItem>
|
|
<SelectItem value="Plantae">Plantae</SelectItem>
|
|
<SelectItem value="Fungi">Fungi</SelectItem>
|
|
<SelectItem value="Protista">Protista</SelectItem>
|
|
<SelectItem value="Chromista">Chromista</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="phylum"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Phylum*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Chordata" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="class"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Class*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Mammalia" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="order_name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Order*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Carnivora" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="family"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Family*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Ursidae" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="genus"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Genus*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Ursus" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="species_name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Species*</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. maritimus" />
|
|
</FormControl>
|
|
<FormDescription>
|
|
Specific epithet (species part of the binomial name)
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="authority"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Authority</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Phipps, 1774" value={field.value || ''}/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Name and year of the authority who described the species
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Description Tab */}
|
|
<TabsContent value="description" className="space-y-4 pt-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>General Description</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
value={field.value || ''}
|
|
placeholder="General description of the species..."
|
|
className="min-h-64"
|
|
rows={12}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="habitat_description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Habitat Description</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
value={field.value || ''}
|
|
placeholder="Description of the species' habitat..."
|
|
className="min-h-64"
|
|
rows={10}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="population_trend"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Population Trend</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value || 'unknown'}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select trend" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="unknown">Unknown</SelectItem>
|
|
<SelectItem value="increasing">Increasing</SelectItem>
|
|
<SelectItem value="stable">Stable</SelectItem>
|
|
<SelectItem value="decreasing">Decreasing</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="population_size"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Population Size</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
{...field}
|
|
value={field.value === undefined || field.value === null ? '' : String(field.value)}
|
|
onChange={e => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
|
|
placeholder="e.g. 25000"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="generation_length"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Generation Length</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
step="any" // Allows decimal input
|
|
{...field}
|
|
value={field.value === undefined || field.value === null ? '' : String(field.value)}
|
|
onChange={e => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
|
|
placeholder="e.g. 12.3"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="movement_patterns"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Movement Patterns</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="e.g. Migratory/Resident" value={field.value || ''}/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Conservation Tab */}
|
|
<TabsContent value="conservation" className="space-y-4 pt-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="use_and_trade"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Use and Trade</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
value={field.value || ''}
|
|
placeholder="Information on use and trade of this species..."
|
|
className="min-h-64"
|
|
rows={10}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="threats_overview"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Threats Overview</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
value={field.value || ''}
|
|
placeholder="Overview of threats to this species..."
|
|
className="min-h-64"
|
|
rows={10}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="conservation_overview"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Conservation Overview</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
value={field.value || ''}
|
|
placeholder="Overview of conservation efforts for this species..."
|
|
className="min-h-64"
|
|
rows={10}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex justify-end space-x-4">
|
|
<Button type="button" variant="outline" onClick={() => window.history.back()}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{isSubmitting ? 'Saving...' : initialData ? 'Update Species' : 'Create Species'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|