Magnus Smari Smarason 7c3d65dadf
Some checks failed
Build, Lint, and Deploy Arctic Species Portal / test-and-build (push) Failing after 1m0s
Build, Lint, and Deploy Arctic Species Portal / deploy (push) Has been skipped
Fixed CRUD operations for CITES listings, common names, and IUCN assessments. Added admin routes and authentication context. Updated UI components and added new pages for admin functionalities.
2025-05-17 20:58:29 +00:00

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>
);
}