Add remaining components: Implement tab-based species UI, CRUD operations, and supporting hooks for CITES and timeline events

This commit is contained in:
Magnus Smari Smarason
2025-03-27 11:15:12 +00:00
parent bb9e68c262
commit 8197652ddc
14 changed files with 1926 additions and 1169 deletions

250
development.md Normal file
View File

@ -0,0 +1,250 @@
# Arctic Species Tracker - Development Guide
## Tech Stack
### Frontend
- **React 18** with TypeScript for type safety and better developer experience
- **Vite** for fast development and optimized builds
- **TanStack Query** (React Query) for efficient data fetching and caching
- **Tailwind CSS** with ShadcnUI for modern, responsive design
- **Lucide Icons** for consistent iconography
- **jsPDF** for PDF report generation
- **Recharts** for data visualizations
### Backend
- **Supabase** for:
- PostgreSQL database
- Authentication
- Real-time subscriptions
- Row Level Security (RLS)
- API management
### Data Sources Integration
- IUCN Red List API
- CITES Species+ API
- CITES Trade Database
- iNaturalist API for species images
## Development Process
### Phase 1: Setup and Infrastructure (2 weeks)
1. Project initialization and repository setup
2. Development environment configuration
3. Supabase project setup and database schema design
4. CI/CD pipeline with GitHub Actions
5. Development of core components and utilities
### Phase 2: Data Integration (3 weeks)
1. Database schema implementation
2. API integration with IUCN Red List
3. Integration with CITES Species+ API
4. CITES Trade Database connection
5. iNaturalist API integration for species images
6. Data transformation and normalization
### Phase 3: Core Features (4 weeks)
1. Species search functionality
2. Species details view
3. CITES listings display
4. Trade data visualization
5. Timeline implementation
6. Error handling and loading states
### Phase 4: Enhanced Features (3 weeks)
1. PDF report generation
2. Data visualization improvements
3. Advanced filtering
4. Performance optimization
5. Mobile responsiveness
6. Accessibility improvements
### Phase 5: Testing and Refinement (2 weeks)
1. Unit testing
2. Integration testing
3. Performance testing
4. User acceptance testing
5. Bug fixes and optimizations
## Development Timeline
Total Development Time: 14 weeks
```mermaid
gantt
title Arctic Species Tracker Development Timeline
dateFormat YYYY-MM-DD
section Setup
Project Setup :2024-01-01, 1w
Infrastructure :2024-01-08, 1w
section Data
Database Schema :2024-01-15, 1w
API Integration :2024-01-22, 2w
section Core
Basic Features :2024-02-05, 2w
Advanced Features :2024-02-19, 2w
section Enhancement
PDF Reports :2024-03-04, 1w
Visualizations :2024-03-11, 2w
section Testing
Testing & QA :2024-03-25, 2w
```
## Development Team Requirements
### Core Team
- 1 Project Manager
- 2 Frontend Developers
- 1 Backend Developer
- 1 UI/UX Designer
- 1 QA Engineer
### Skills Required
- React/TypeScript expertise
- PostgreSQL database design
- REST API integration
- Data visualization
- UI/UX design
- Testing methodologies
## Deployment Strategy
### Development Environment
- Local development using Vite
- Supabase local development
- GitHub for version control
### Staging Environment
- Vercel preview deployments
- Supabase staging project
- Automated testing
### Production Environment
- GitHub Pages for frontend hosting
- Supabase production instance
- Continuous deployment
## Monitoring and Maintenance
### Performance Monitoring
- Vercel Analytics
- Supabase Dashboard
- Error tracking with Sentry
### Regular Maintenance
- Weekly dependency updates
- Monthly security audits
- Quarterly performance reviews
## Future Enhancements
### Planned Features
1. Advanced search filters
2. Batch report generation
3. Data export options
4. User accounts and preferences
5. Interactive maps
6. Real-time notifications
### CRUD Operations Improvements
1. **Enhanced IUCN Assessment Management**
- Implement full CRUD interface for IUCN assessments
- Add validation for assessment data
- Create hooks similar to `useCitesListingCrud` for IUCN assessments
- Implement optimistic updates for better UX
2. **Subpopulation Management**
- Create dedicated components for subpopulation CRUD
- Add geospatial data support for Arctic regions
- Implement region-specific conservation status tracking
- Add visualization for subpopulation distribution
3. **Common Names Management**
- Build multilingual support for common names
- Add language/region tagging for names
- Implement search by any common name
- Create bulk import/export functionality
4. **Bulk Operations**
- Develop batch processing system for multiple records
- Create CSV import/export functionality
- Implement transaction-based operations for data integrity
- Add progress tracking for bulk operations
### Trade Data Filtering Improvements
1. **Advanced Filtering System**
- Refactor `useTradeRecordFilters` to support multiple filter types
- Implement filter composition with AND/OR logic
- Create persistent filter state with URL parameters
- Add filter presets and user-saved filters
2. **Geographic and Quantity Filtering**
- Add geospatial filtering by country/region
- Implement trade route visualization
- Create quantity-based threshold filters
- Add time-series analysis for quantity changes
3. **Filter UI Enhancements**
- Design multi-select filter components
- Implement date range picker for temporal filtering
- Create visual filter builder interface
- Add filter state visualization
### Timeline Enhancement Implementation
1. **Conservation Status Change Indicators**
- Add visual flags to timeline for status changes
- Implement correlation analysis between status changes and trade
- Create hover tooltips with detailed change information
- Add filtering by change significance
2. **Cause-Effect Analysis**
- Develop algorithms to detect potential correlations
- Implement visual connections between related events
- Create statistical analysis of trade pattern changes
- Add predictive indicators based on historical patterns
3. **Timeline Visualization Improvements**
- Implement side-by-side species comparison
- Add zooming and scaling for timeline view
- Create custom event categories and color coding
- Implement timeline export and sharing
### Technical Improvements
1. Bundle size optimization
2. Server-side rendering
3. Progressive Web App (PWA)
4. Automated data updates
5. Enhanced caching strategies
## Development Best Practices
### Code Quality
- TypeScript for type safety
- ESLint for code linting
- Prettier for code formatting
- Husky for pre-commit hooks
### Documentation
- Comprehensive README
- API documentation
- Component documentation
- Development guides
### Testing
- Jest for unit testing
- React Testing Library
- E2E testing with Cypress
- Regular performance audits
## Security Considerations
### Data Protection
- API key management
- Rate limiting
- Data encryption
- Regular security audits
### Compliance
- GDPR compliance
- Data retention policies
- Attribution requirements
- API usage agreements

23
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.24.1",
"@tanstack/react-query-devtools": "^5.24.1",
"@types/uuid": "^10.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.3.1",
@ -29,7 +30,8 @@
"react-router-dom": "^6.22.1",
"recharts": "^2.12.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^20.11.20",
@ -2508,6 +2510,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
@ -6522,6 +6530,19 @@
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@ -22,6 +22,7 @@
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.24.1",
"@tanstack/react-query-devtools": "^5.24.1",
"@types/uuid": "^10.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.3.1",
@ -34,7 +35,8 @@
"react-router-dom": "^6.22.1",
"recharts": "^2.12.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^20.11.20",

View File

@ -3,7 +3,6 @@ import { Button } from '@/components/ui/button';
import { FileText, Loader2 } from 'lucide-react';
import { SpeciesDetails } from '@/lib/api';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { formatDate } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { getCitesTradeRecords } from '@/lib/api';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ImageIcon, X } from "lucide-react";
type SpeciesImageProps = {
scientificName: string;
imageData?: {
url: string;
attribution: string;
license?: string;
} | null;
};
export function SpeciesImage({ scientificName, imageData }: SpeciesImageProps) {
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false);
return (
<>
<div
className="relative aspect-[16/9] w-full overflow-hidden rounded-lg bg-muted mb-4 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => imageData && setIsImageDialogOpen(true)}
>
{imageData ? (
<>
<img
src={imageData.url}
alt={scientificName}
className="object-cover w-full h-full"
/>
<div className="absolute bottom-0 right-0 bg-black/50 p-2 text-xs text-white">
{imageData.attribution}
</div>
<div className="absolute top-2 right-2 bg-black/50 p-1 rounded-full">
<ImageIcon className="h-4 w-4 text-white" />
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<ImageIcon className="h-12 w-12" />
<span className="ml-2">No image available</span>
</div>
)}
</div>
{/* Dialog for full-size image */}
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{scientificName}</span>
<Button
variant="ghost"
size="icon"
onClick={() => setIsImageDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
</DialogHeader>
{imageData && (
<div className="relative">
<img
src={imageData.url.replace('medium', 'original')}
alt={scientificName}
className="w-full h-auto rounded-lg"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 p-4 text-white rounded-b-lg">
<p className="text-sm">{imageData.attribution}</p>
{imageData.license && (
<p className="text-xs mt-1">License: {imageData.license}</p>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,257 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertCircle, Edit, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { SpeciesDetails, CitesListing } from "@/lib/api";
import { formatDate, CITES_APPENDIX_COLORS } from "@/lib/utils";
import { useCitesListingCrud } from "@/hooks/useCitesListingCrud";
type CitesTabProps = {
species: SpeciesDetails;
};
export function CitesTab({ species }: CitesTabProps) {
const {
isEditingCitesListing,
isAddingCitesListing,
selectedCitesListing,
citesListingForm,
operationStatus,
handleCitesListingFormChange,
handleEditCitesListing,
handleAddCitesListing,
handleSaveCitesListing,
handleDeleteCitesListing,
handleCancelCitesListing
} = useCitesListingCrud(species.id);
return (
<Card>
<CardHeader>
<CardTitle>CITES Listings</CardTitle>
<CardDescription>Convention on International Trade in Endangered Species listings</CardDescription>
</CardHeader>
<CardContent>
{species.cites_listings.length > 0 ? (
<div className="space-y-6">
{/* Current CITES status highlight */}
{species.current_cites_listing && (
<div className="rounded-md bg-muted p-4">
<h3 className="mb-2 text-lg font-semibold">Current CITES Status</h3>
<div className="flex items-center gap-2">
<Badge className={`${CITES_APPENDIX_COLORS[species.current_cites_listing.appendix]} text-lg px-3 py-1`}>
Appendix {species.current_cites_listing.appendix}
</Badge>
<span>since {formatDate(species.current_cites_listing.listing_date)}</span>
</div>
{species.current_cites_listing.notes && (
<p className="mt-2 text-sm">{species.current_cites_listing.notes}</p>
)}
</div>
)}
{/* Operation status messages */}
{operationStatus.error && (
<div className="rounded-md bg-red-50 p-4 flex items-start mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-red-800">Error</h4>
<p className="text-sm text-red-700 mt-1">{operationStatus.error}</p>
</div>
</div>
)}
{operationStatus.success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<p className="text-sm text-green-700">Operation completed successfully.</p>
</div>
)}
{/* CITES listing form */}
{(isAddingCitesListing || isEditingCitesListing) && (
<div className="rounded-md border p-4 mb-6">
<h3 className="text-lg font-semibold mb-4">
{isAddingCitesListing ? "Add New CITES Listing" : "Edit CITES Listing"}
</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="appendix">Appendix</Label>
<Select
value={citesListingForm.appendix}
onValueChange={(value) => handleCitesListingFormChange("appendix", value)}
>
<SelectTrigger id="appendix">
<SelectValue placeholder="Select Appendix" />
</SelectTrigger>
<SelectContent>
<SelectItem value="I">Appendix I</SelectItem>
<SelectItem value="II">Appendix II</SelectItem>
<SelectItem value="III">Appendix III</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="listing-date">Listing Date</Label>
<input
id="listing-date"
type="date"
value={citesListingForm.listing_date}
onChange={(e) => handleCitesListingFormChange("listing_date", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes">Notes</Label>
<textarea
id="notes"
value={citesListingForm.notes}
onChange={(e) => handleCitesListingFormChange("notes", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background min-h-[100px]"
placeholder="Optional notes about this listing"
/>
</div>
<div className="flex items-center space-x-2">
<input
id="is-current"
type="checkbox"
checked={citesListingForm.is_current}
onChange={(e) => handleCitesListingFormChange("is_current", e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Label htmlFor="is-current" className="text-sm font-normal">
Set as current listing
</Label>
</div>
</div>
<div className="mt-6 flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleCancelCitesListing}
>
Cancel
</Button>
<Button
onClick={handleSaveCitesListing}
disabled={operationStatus.loading}
>
{operationStatus.loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save
</>
)}
</Button>
</div>
</div>
)}
{/* CITES listings table */}
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Listing History ({species.cites_listings.length} listings)</h3>
<Button
onClick={handleAddCitesListing}
disabled={isAddingCitesListing || isEditingCitesListing}
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Listing
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full table-auto">
<thead>
<tr className="border-b">
<th className="py-2 text-left">Appendix</th>
<th className="py-2 text-left">Listing Date</th>
<th className="py-2 text-left">Notes</th>
<th className="py-2 text-left">Current</th>
<th className="py-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{species.cites_listings.length === 0 ? (
<tr>
<td colSpan={5} className="py-4 text-center text-muted-foreground">
No listing history available
</td>
</tr>
) : (
species.cites_listings.map((listing: CitesListing) => (
<tr key={listing.id} className="border-b">
<td className="py-2">
<Badge className={CITES_APPENDIX_COLORS[listing.appendix]}>
Appendix {listing.appendix}
</Badge>
</td>
<td className="py-2">
{formatDate(listing.listing_date || '')}
</td>
<td className="py-2">{listing.notes || 'N/A'}</td>
<td className="py-2">
{listing.is_current && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
Current
</Badge>
)}
</td>
<td className="py-2">
<div className="flex space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditCitesListing(listing)}
disabled={isAddingCitesListing || isEditingCitesListing}
title="Edit listing"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteCitesListing(listing)}
disabled={isAddingCitesListing || isEditingCitesListing || operationStatus.loading}
title="Delete listing"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* CITES info section */}
<div className="rounded-md bg-blue-50 p-4 text-sm">
<h3 className="mb-2 font-medium text-blue-700">What are CITES Appendices?</h3>
<ul className="list-inside list-disc space-y-1 text-blue-700">
<li><strong>Appendix I</strong>: Species threatened with extinction. Trade permitted only in exceptional circumstances.</li>
<li><strong>Appendix II</strong>: Species not necessarily threatened with extinction, but trade must be controlled to avoid utilization incompatible with their survival.</li>
<li><strong>Appendix III</strong>: Species protected in at least one country, which has asked other CITES Parties for assistance in controlling the trade.</li>
</ul>
</div>
</div>
) : (
<p className="text-muted-foreground">No CITES listings available for this species.</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,120 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { SpeciesDetails, IucnAssessment } from "@/lib/api";
import { IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES } from "@/lib/utils";
type IucnTabProps = {
species: SpeciesDetails;
};
export function IucnTab({ species }: IucnTabProps) {
return (
<Card>
<CardHeader>
<CardTitle>IUCN Red List Assessments</CardTitle>
<CardDescription>International Union for Conservation of Nature assessments</CardDescription>
</CardHeader>
<CardContent>
{species.iucn_assessments.length > 0 ? (
<div className="space-y-6">
{/* Current Status Highlight */}
{species.latest_assessment && (
<div className="rounded-md bg-muted p-4">
<h3 className="mb-2 text-lg font-semibold">Current Status</h3>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{species.latest_assessment.status && (
<Badge
className={IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500'}
variant="default"
>
{IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status}
</Badge>
)}
<span className="text-sm text-muted-foreground">
({species.latest_assessment.year_published})
</span>
</div>
{species.latest_assessment.possibly_extinct && (
<span className="text-sm text-red-600">
Possibly Extinct
</span>
)}
{species.latest_assessment.possibly_extinct_in_wild && (
<span className="text-sm text-orange-600">
Possibly Extinct in the Wild
</span>
)}
</div>
</div>
)}
{/* Assessment History */}
<div>
<h3 className="mb-4 text-lg font-semibold">Assessment History</h3>
<div className="space-y-4">
{species.iucn_assessments
.sort((a, b) => b.year_published - a.year_published)
.map((assessment) => (
<div key={assessment.id} className="border-b pb-4 last:border-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{assessment.year_published}</span>
<span></span>
{assessment.status && (
<Badge
className={IUCN_STATUS_COLORS[assessment.status] || 'bg-gray-500'}
variant="default"
>
{IUCN_STATUS_FULL_NAMES[assessment.status] || assessment.status}
</Badge>
)}
</div>
{assessment.scope_description && (
<p className="text-sm text-muted-foreground mb-2">
{assessment.scope_description}
</p>
)}
{assessment.url && (
<a
href={assessment.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View Assessment
</a>
)}
</div>
))}
</div>
</div>
{/* IUCN Info Section */}
<div className="rounded-md bg-blue-50 p-4 text-sm">
<h3 className="mb-2 font-medium text-blue-700">About IUCN Red List Assessments</h3>
<p className="text-blue-700">
The IUCN Red List of Threatened Species is the world's most comprehensive information source on the global extinction risk status of animal, fungus and plant species.
</p>
<p className="mt-2 text-blue-700">
Status categories:
</p>
<ul className="list-inside list-disc mt-1 text-blue-700">
<li><strong>EX</strong> - Extinct</li>
<li><strong>EW</strong> - Extinct in the Wild</li>
<li><strong>CR</strong> - Critically Endangered</li>
<li><strong>EN</strong> - Endangered</li>
<li><strong>VU</strong> - Vulnerable</li>
<li><strong>NT</strong> - Near Threatened</li>
<li><strong>LC</strong> - Least Concern</li>
<li><strong>DD</strong> - Data Deficient</li>
<li><strong>NE</strong> - Not Evaluated</li>
</ul>
</div>
</div>
) : (
<p className="text-muted-foreground">No IUCN assessments available for this species.</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,115 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { SpeciesDetails } from "@/lib/api";
import { formatDate, IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES, CITES_APPENDIX_COLORS } from "@/lib/utils";
import { SpeciesImage } from "../SpeciesImage";
type OverviewTabProps = {
species: SpeciesDetails;
imageData?: {
url: string;
attribution: string;
license?: string;
} | null;
};
export function OverviewTab({ species, imageData }: OverviewTabProps) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Species Information</CardTitle>
<CardDescription>Taxonomic classification and basic info</CardDescription>
</CardHeader>
<CardContent>
{/* Species image */}
<SpeciesImage scientificName={species.scientific_name} imageData={imageData} />
<dl className="grid grid-cols-2 gap-2">
<dt className="font-semibold">Scientific Name:</dt>
<dd className="italic">{species.scientific_name}</dd>
<dt className="font-semibold">Common Name:</dt>
<dd>{species.common_name}</dd>
<dt className="font-semibold">Kingdom:</dt>
<dd>{species.kingdom}</dd>
<dt className="font-semibold">Phylum:</dt>
<dd>{species.phylum}</dd>
<dt className="font-semibold">Class:</dt>
<dd>{species.class}</dd>
<dt className="font-semibold">Order:</dt>
<dd>{species.order_name}</dd>
<dt className="font-semibold">Family:</dt>
<dd>{species.family}</dd>
<dt className="font-semibold">Genus:</dt>
<dd>{species.genus}</dd>
<dt className="font-semibold">Species:</dt>
<dd>{species.species_name}</dd>
{species.authority && (
<>
<dt className="font-semibold">Authority:</dt>
<dd>{species.authority}</dd>
</>
)}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Conservation Status</CardTitle>
<CardDescription>Current protection and assessment</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h4 className="mb-2 text-sm font-semibold">IUCN Red List Status:</h4>
{species.latest_assessment ? (
<div className="flex flex-col gap-2">
{species.latest_assessment.status && (
<Badge
className={IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500'}
variant="default"
>
{IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status}
</Badge>
)}
<span className="text-sm">
Assessment Year: {species.latest_assessment.year_published}
</span>
{species.latest_assessment.possibly_extinct && (
<span className="text-sm text-red-600">
Possibly Extinct
</span>
)}
{species.latest_assessment.possibly_extinct_in_wild && (
<span className="text-sm text-orange-600">
Possibly Extinct in the Wild
</span>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">No assessment data available</span>
)}
</div>
<div>
<h4 className="mb-2 text-sm font-semibold">CITES Listing:</h4>
{species.current_cites_listing ? (
<div className="flex flex-col gap-2">
<Badge className={CITES_APPENDIX_COLORS[species.current_cites_listing.appendix]}>
Appendix {species.current_cites_listing.appendix}
</Badge>
<span className="text-sm">
Listed since: {formatDate(species.current_cites_listing.listing_date)}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">No CITES listing data available</span>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,241 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertCircle, Edit, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { SpeciesDetails, TimelineEvent } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { useTimelineEventCrud } from "@/hooks/useTimelineEventCrud";
import { useQuery } from "@tanstack/react-query";
import { getTimelineEvents } from "@/lib/api";
type TimelineTabProps = {
species: SpeciesDetails;
};
export function TimelineTab({ species }: TimelineTabProps) {
const { data: timelineEvents, isLoading: timelineLoading } = useQuery({
queryKey: ["timelineEvents", species.id],
queryFn: () => getTimelineEvents(species.id),
});
const {
isEditingTimelineEvent,
isAddingTimelineEvent,
selectedTimelineEvent,
timelineEventForm,
operationStatus,
handleTimelineEventFormChange,
handleEditTimelineEvent,
handleAddTimelineEvent,
handleSaveTimelineEvent,
handleDeleteTimelineEvent,
handleCancelTimelineEvent
} = useTimelineEventCrud(species.id);
return (
<Card>
<CardHeader>
<CardTitle>Conservation Timeline</CardTitle>
<CardDescription>Historical conservation and trade events</CardDescription>
</CardHeader>
<CardContent>
{/* Operation status messages */}
{operationStatus.error && (
<div className="rounded-md bg-red-50 p-4 flex items-start mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-red-800">Error</h4>
<p className="text-sm text-red-700 mt-1">{operationStatus.error}</p>
</div>
</div>
)}
{operationStatus.success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<p className="text-sm text-green-700">Operation completed successfully.</p>
</div>
)}
{/* Timeline event form */}
{(isAddingTimelineEvent || isEditingTimelineEvent) && (
<div className="rounded-md border p-4 mb-6">
<h3 className="text-lg font-semibold mb-4">
{isAddingTimelineEvent ? "Add New Timeline Event" : "Edit Timeline Event"}
</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="event-date">Event Date</Label>
<input
id="event-date"
type="date"
value={timelineEventForm.event_date}
onChange={(e) => {
handleTimelineEventFormChange("event_date", e.target.value);
// Also update the year based on the date
const year = new Date(e.target.value).getFullYear();
handleTimelineEventFormChange("year", year);
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-type">Event Type</Label>
<Select
value={timelineEventForm.event_type}
onValueChange={(value) => handleTimelineEventFormChange("event_type", value)}
>
<SelectTrigger id="event-type">
<SelectValue placeholder="Select Event Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cites_listing">CITES Listing</SelectItem>
<SelectItem value="iucn_assessment">IUCN Assessment</SelectItem>
<SelectItem value="conservation_action">Conservation Action</SelectItem>
<SelectItem value="population_trend">Population Trend</SelectItem>
<SelectItem value="legislation">Legislation</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="title">Title</Label>
<input
id="title"
type="text"
value={timelineEventForm.title}
onChange={(e) => handleTimelineEventFormChange("title", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
placeholder="Event title"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="description">Description</Label>
<textarea
id="description"
value={timelineEventForm.description}
onChange={(e) => handleTimelineEventFormChange("description", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background min-h-[100px]"
placeholder="Optional description of this event"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status (Optional)</Label>
<input
id="status"
type="text"
value={timelineEventForm.status}
onChange={(e) => handleTimelineEventFormChange("status", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
placeholder="e.g., Completed, In Progress, etc."
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleCancelTimelineEvent}
>
Cancel
</Button>
<Button
onClick={handleSaveTimelineEvent}
disabled={operationStatus.loading || !timelineEventForm.title}
>
{operationStatus.loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save
</>
)}
</Button>
</div>
</div>
)}
{/* Timeline events list */}
{timelineLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
Timeline Events ({timelineEvents?.length || 0})
</h3>
<Button
onClick={handleAddTimelineEvent}
disabled={isAddingTimelineEvent || isEditingTimelineEvent}
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Event
</Button>
</div>
{timelineEvents && timelineEvents.length > 0 ? (
<div className="relative ml-4 space-y-6 border-l border-muted pl-6 pt-2">
{timelineEvents.map((event: TimelineEvent) => (
<div key={event.id} className="relative">
<div className="absolute -left-10 top-1 h-4 w-4 rounded-full bg-primary"></div>
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center text-sm">
<span className="font-medium">{formatDate(event.event_date)}</span>
{event.status && (
<Badge
className="ml-2"
variant={event.event_type === 'cites_listing' ? 'default' : 'secondary'}
>
{event.status}
</Badge>
)}
</div>
<div className="flex space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditTimelineEvent(event)}
disabled={isAddingTimelineEvent || isEditingTimelineEvent}
title="Edit event"
className="h-8 w-8"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTimelineEvent(event)}
disabled={isAddingTimelineEvent || isEditingTimelineEvent || operationStatus.loading}
title="Delete event"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<h4 className="text-base font-medium">{event.title}</h4>
{event.description && <p className="text-sm text-muted-foreground">{event.description}</p>}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No timeline events available for this species.</p>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,173 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CitesListing } from "@/lib/api";
import { createCitesListing, updateCitesListing, deleteCitesListing } from "@/lib/api";
type CitesListingForm = {
appendix: string;
listing_date: string;
notes: string;
is_current: boolean;
};
type OperationStatus = {
loading: boolean;
success: boolean;
error: string | null;
};
export function useCitesListingCrud(speciesId: string) {
const queryClient = useQueryClient();
const [isEditingCitesListing, setIsEditingCitesListing] = useState(false);
const [isAddingCitesListing, setIsAddingCitesListing] = useState(false);
const [selectedCitesListing, setSelectedCitesListing] = useState<CitesListing | null>(null);
const [citesListingForm, setCitesListingForm] = useState<CitesListingForm>({
appendix: "I",
listing_date: new Date().toISOString().split('T')[0],
notes: "",
is_current: false
});
const [operationStatus, setOperationStatus] = useState<OperationStatus>({
loading: false,
success: false,
error: null
});
// Reset form when adding new CITES listing
const resetCitesListingForm = () => {
setCitesListingForm({
appendix: "I",
listing_date: new Date().toISOString().split('T')[0],
notes: "",
is_current: false
});
};
// Handle CITES listing form changes
const handleCitesListingFormChange = (field: string, value: any) => {
setCitesListingForm(prev => ({
...prev,
[field]: value
}));
};
// Handle edit CITES listing
const handleEditCitesListing = (listing: CitesListing) => {
setSelectedCitesListing(listing);
setCitesListingForm({
appendix: listing.appendix,
listing_date: listing.listing_date.split('T')[0],
notes: listing.notes || "",
is_current: listing.is_current
});
setIsEditingCitesListing(true);
setIsAddingCitesListing(false);
};
// Handle add new CITES listing
const handleAddCitesListing = () => {
resetCitesListingForm();
setSelectedCitesListing(null);
setIsAddingCitesListing(true);
setIsEditingCitesListing(false);
};
// Handle save CITES listing
const handleSaveCitesListing = async () => {
setOperationStatus({ loading: true, success: false, error: null });
try {
if (isEditingCitesListing && selectedCitesListing) {
// Update existing listing
await updateCitesListing(selectedCitesListing.id, {
appendix: citesListingForm.appendix,
listing_date: citesListingForm.listing_date,
notes: citesListingForm.notes || null,
is_current: citesListingForm.is_current
});
} else if (isAddingCitesListing) {
// Create new listing
await createCitesListing({
species_id: speciesId,
appendix: citesListingForm.appendix,
listing_date: citesListingForm.listing_date,
notes: citesListingForm.notes || null,
is_current: citesListingForm.is_current
});
}
// Reset state and refresh data
setOperationStatus({ loading: false, success: true, error: null });
setIsEditingCitesListing(false);
setIsAddingCitesListing(false);
setSelectedCitesListing(null);
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ["species", speciesId] });
// Clear success message after a delay
setTimeout(() => {
setOperationStatus(prev => ({ ...prev, success: false }));
}, 3000);
} catch (error) {
console.error('Error saving CITES listing:', error);
setOperationStatus({
loading: false,
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
});
}
};
// Handle delete CITES listing
const handleDeleteCitesListing = async (listing: CitesListing) => {
if (!confirm(`Are you sure you want to delete this CITES listing (Appendix ${listing.appendix} from ${new Date(listing.listing_date).toLocaleDateString()})?`)) {
return;
}
setOperationStatus({ loading: true, success: false, error: null });
try {
await deleteCitesListing(listing.id);
// Reset state and refresh data
setOperationStatus({ loading: false, success: true, error: null });
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ["species", speciesId] });
// Clear success message after a delay
setTimeout(() => {
setOperationStatus(prev => ({ ...prev, success: false }));
}, 3000);
} catch (error) {
console.error('Error deleting CITES listing:', error);
setOperationStatus({
loading: false,
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
});
}
};
// Cancel editing/adding
const handleCancelCitesListing = () => {
setIsAddingCitesListing(false);
setIsEditingCitesListing(false);
setSelectedCitesListing(null);
};
return {
isEditingCitesListing,
isAddingCitesListing,
selectedCitesListing,
citesListingForm,
operationStatus,
handleCitesListingFormChange,
handleEditCitesListing,
handleAddCitesListing,
handleSaveCitesListing,
handleDeleteCitesListing,
handleCancelCitesListing
};
}

View File

@ -0,0 +1,192 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { TimelineEvent } from "@/lib/api";
import { createTimelineEvent, updateTimelineEvent, deleteTimelineEvent } from "@/lib/api";
type TimelineEventForm = {
event_date: string;
year: number;
event_type: string;
title: string;
description: string;
status: string;
source_type: string;
};
type OperationStatus = {
loading: boolean;
success: boolean;
error: string | null;
};
export function useTimelineEventCrud(speciesId: string) {
const queryClient = useQueryClient();
const [isEditingTimelineEvent, setIsEditingTimelineEvent] = useState(false);
const [isAddingTimelineEvent, setIsAddingTimelineEvent] = useState(false);
const [selectedTimelineEvent, setSelectedTimelineEvent] = useState<TimelineEvent | null>(null);
const [timelineEventForm, setTimelineEventForm] = useState<TimelineEventForm>({
event_date: new Date().toISOString().split('T')[0],
year: new Date().getFullYear(),
event_type: "cites_listing",
title: "",
description: "",
status: "",
source_type: "manual"
});
const [operationStatus, setOperationStatus] = useState<OperationStatus>({
loading: false,
success: false,
error: null
});
// Reset form when adding new timeline event
const resetTimelineEventForm = () => {
setTimelineEventForm({
event_date: new Date().toISOString().split('T')[0],
year: new Date().getFullYear(),
event_type: "cites_listing",
title: "",
description: "",
status: "",
source_type: "manual"
});
};
// Handle timeline event form changes
const handleTimelineEventFormChange = (field: string, value: any) => {
setTimelineEventForm(prev => ({
...prev,
[field]: value
}));
};
// Handle edit timeline event
const handleEditTimelineEvent = (event: TimelineEvent) => {
setSelectedTimelineEvent(event);
setTimelineEventForm({
event_date: event.event_date.split('T')[0],
year: event.year,
event_type: event.event_type,
title: event.title,
description: event.description || "",
status: event.status || "",
source_type: event.source_type
});
setIsEditingTimelineEvent(true);
setIsAddingTimelineEvent(false);
};
// Handle add new timeline event
const handleAddTimelineEvent = () => {
resetTimelineEventForm();
setSelectedTimelineEvent(null);
setIsAddingTimelineEvent(true);
setIsEditingTimelineEvent(false);
};
// Handle save timeline event
const handleSaveTimelineEvent = async () => {
setOperationStatus({ loading: true, success: false, error: null });
try {
if (isEditingTimelineEvent && selectedTimelineEvent) {
// Update existing event
await updateTimelineEvent(selectedTimelineEvent.id, {
event_date: timelineEventForm.event_date,
year: timelineEventForm.year,
event_type: timelineEventForm.event_type,
title: timelineEventForm.title,
description: timelineEventForm.description || null,
status: timelineEventForm.status || null,
source_type: timelineEventForm.source_type
});
} else if (isAddingTimelineEvent) {
// Create new event
await createTimelineEvent({
species_id: speciesId,
event_date: timelineEventForm.event_date,
year: timelineEventForm.year,
event_type: timelineEventForm.event_type,
title: timelineEventForm.title,
description: timelineEventForm.description || null,
status: timelineEventForm.status || null,
source_type: timelineEventForm.source_type,
source_id: null
});
}
// Reset state and refresh data
setOperationStatus({ loading: false, success: true, error: null });
setIsEditingTimelineEvent(false);
setIsAddingTimelineEvent(false);
setSelectedTimelineEvent(null);
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ["timelineEvents", speciesId] });
// Clear success message after a delay
setTimeout(() => {
setOperationStatus(prev => ({ ...prev, success: false }));
}, 3000);
} catch (error) {
console.error('Error saving timeline event:', error);
setOperationStatus({
loading: false,
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
});
}
};
// Handle delete timeline event
const handleDeleteTimelineEvent = async (event: TimelineEvent) => {
if (!confirm(`Are you sure you want to delete this timeline event "${event.title}"?`)) {
return;
}
setOperationStatus({ loading: true, success: false, error: null });
try {
await deleteTimelineEvent(event.id);
// Reset state and refresh data
setOperationStatus({ loading: false, success: true, error: null });
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ["timelineEvents", speciesId] });
// Clear success message after a delay
setTimeout(() => {
setOperationStatus(prev => ({ ...prev, success: false }));
}, 3000);
} catch (error) {
console.error('Error deleting timeline event:', error);
setOperationStatus({
loading: false,
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
});
}
};
// Cancel editing/adding
const handleCancelTimelineEvent = () => {
setIsAddingTimelineEvent(false);
setIsEditingTimelineEvent(false);
setSelectedTimelineEvent(null);
};
return {
isEditingTimelineEvent,
isAddingTimelineEvent,
selectedTimelineEvent,
timelineEventForm,
operationStatus,
handleTimelineEventFormChange,
handleEditTimelineEvent,
handleAddTimelineEvent,
handleSaveTimelineEvent,
handleDeleteTimelineEvent,
handleCancelTimelineEvent
};
}

View File

@ -1,5 +1,6 @@
import { supabase } from './supabase';
import { Database } from '../types/supabase';
import { v4 as uuidv4 } from 'uuid';
export type Species = Database['public']['Tables']['species']['Row'] & {
common_names?: CommonName[];
@ -404,4 +405,373 @@ export async function getSpeciesImages(scientificName: string) {
console.error('Error fetching species images:', error);
return null;
}
}
}
// CRUD functions for CITES listings
export async function createCitesListing(listing: Omit<Database['public']['Tables']['cites_listings']['Insert'], 'id'>) {
try {
const newListing = {
...listing,
id: uuidv4()
};
console.log('Creating new CITES listing:', newListing);
const { data, error } = await supabase
.from('cites_listings')
.insert(newListing)
.select()
.single();
if (error) {
console.error('Error creating CITES listing:', error);
throw error;
}
// If this is set as current, update other listings to not be current
if (newListing.is_current) {
await updateCitesListingsCurrent(newListing.species_id, newListing.id);
}
return data;
} catch (error) {
console.error('Error in createCitesListing:', error);
throw error;
}
}
export async function updateCitesListing(id: string, updates: Database['public']['Tables']['cites_listings']['Update']) {
try {
console.log(`Updating CITES listing ${id}:`, updates);
// First get the current listing to check if we need to update current status
const { data: currentListing, error: fetchError } = await supabase
.from('cites_listings')
.select('species_id')
.eq('id', id)
.single();
if (fetchError) {
console.error('Error fetching CITES listing for update:', fetchError);
throw fetchError;
}
// Update the listing
const { data, error } = await supabase
.from('cites_listings')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) {
console.error('Error updating CITES listing:', error);
throw error;
}
// If this is set as current, update other listings to not be current
if (updates.is_current && currentListing) {
await updateCitesListingsCurrent(currentListing.species_id, id);
}
return data;
} catch (error) {
console.error('Error in updateCitesListing:', error);
throw error;
}
}
export async function deleteCitesListing(id: string) {
try {
console.log(`Deleting CITES listing ${id}`);
// First get the current listing to check if we need to update current status
const { data: currentListing, error: fetchError } = await supabase
.from('cites_listings')
.select('species_id, is_current')
.eq('id', id)
.single();
if (fetchError) {
console.error('Error fetching CITES listing for deletion:', fetchError);
throw fetchError;
}
// Delete the listing
const { error } = await supabase
.from('cites_listings')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting CITES listing:', error);
throw error;
}
// If this was the current listing, set another one as current
if (currentListing && currentListing.is_current) {
const { data: remainingListings, error: listingsError } = await supabase
.from('cites_listings')
.select('id')
.eq('species_id', currentListing.species_id)
.order('listing_date', { ascending: false })
.limit(1);
if (listingsError) {
console.error('Error fetching remaining listings:', listingsError);
} else if (remainingListings && remainingListings.length > 0) {
await updateCitesListing(remainingListings[0].id, { is_current: true });
}
}
return true;
} catch (error) {
console.error('Error in deleteCitesListing:', error);
throw error;
}
}
// Helper function to update current status of CITES listings
async function updateCitesListingsCurrent(speciesId: string, currentId: string) {
try {
console.log(`Setting listing ${currentId} as current for species ${speciesId}`);
const { error } = await supabase
.from('cites_listings')
.update({ is_current: false })
.eq('species_id', speciesId)
.neq('id', currentId);
if (error) {
console.error('Error updating CITES listings current status:', error);
throw error;
}
return true;
} catch (error) {
console.error('Error in updateCitesListingsCurrent:', error);
throw error;
}
}
// CRUD functions for timeline events
export async function createTimelineEvent(event: Omit<Database['public']['Tables']['timeline_events']['Insert'], 'id'>) {
try {
const newEvent = {
...event,
id: uuidv4()
};
console.log('Creating new timeline event:', newEvent);
const { data, error } = await supabase
.from('timeline_events')
.insert(newEvent)
.select()
.single();
if (error) {
console.error('Error creating timeline event:', error);
throw error;
}
return data;
} catch (error) {
console.error('Error in createTimelineEvent:', error);
throw error;
}
}
export async function updateTimelineEvent(id: string, updates: Database['public']['Tables']['timeline_events']['Update']) {
try {
console.log(`Updating timeline event ${id}:`, updates);
const { data, error } = await supabase
.from('timeline_events')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) {
console.error('Error updating timeline event:', error);
throw error;
}
return data;
} catch (error) {
console.error('Error in updateTimelineEvent:', error);
throw error;
}
}
export async function deleteTimelineEvent(id: string) {
try {
console.log(`Deleting timeline event ${id}`);
const { error } = await supabase
.from('timeline_events')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting timeline event:', error);
throw error;
}
return true;
} catch (error) {
console.error('Error in deleteTimelineEvent:', error);
throw error;
}
}
// CRUD functions for IUCN assessments
export async function createIucnAssessment(assessment: Omit<Database['public']['Tables']['iucn_assessments']['Insert'], 'id'>) {
try {
const newAssessment = {
...assessment,
id: uuidv4()
};
console.log('Creating new IUCN assessment:', newAssessment);
const { data, error } = await supabase
.from('iucn_assessments')
.insert(newAssessment)
.select()
.single();
if (error) {
console.error('Error creating IUCN assessment:', error);
throw error;
}
// If this is set as latest, update other assessments to not be latest
if (newAssessment.is_latest) {
await updateIucnAssessmentsLatest(newAssessment.species_id, newAssessment.id);
}
return data;
} catch (error) {
console.error('Error in createIucnAssessment:', error);
throw error;
}
}
export async function updateIucnAssessment(id: string, updates: Database['public']['Tables']['iucn_assessments']['Update']) {
try {
console.log(`Updating IUCN assessment ${id}:`, updates);
// First get the current assessment to check if we need to update latest status
const { data: currentAssessment, error: fetchError } = await supabase
.from('iucn_assessments')
.select('species_id')
.eq('id', id)
.single();
if (fetchError) {
console.error('Error fetching IUCN assessment for update:', fetchError);
throw fetchError;
}
// Update the assessment
const { data, error } = await supabase
.from('iucn_assessments')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) {
console.error('Error updating IUCN assessment:', error);
throw error;
}
// If this is set as latest, update other assessments to not be latest
if (updates.is_latest && currentAssessment) {
await updateIucnAssessmentsLatest(currentAssessment.species_id, id);
}
return data;
} catch (error) {
console.error('Error in updateIucnAssessment:', error);
throw error;
}
}
export async function deleteIucnAssessment(id: string) {
try {
console.log(`Deleting IUCN assessment ${id}`);
// First get the current assessment to check if we need to update latest status
const { data: currentAssessment, error: fetchError } = await supabase
.from('iucn_assessments')
.select('species_id, is_latest')
.eq('id', id)
.single();
if (fetchError) {
console.error('Error fetching IUCN assessment for deletion:', fetchError);
throw fetchError;
}
// Delete the assessment
const { error } = await supabase
.from('iucn_assessments')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting IUCN assessment:', error);
throw error;
}
// If this was the latest assessment, set another one as latest
if (currentAssessment && currentAssessment.is_latest) {
const { data: remainingAssessments, error: assessmentsError } = await supabase
.from('iucn_assessments')
.select('id')
.eq('species_id', currentAssessment.species_id)
.order('year_published', { ascending: false })
.limit(1);
if (assessmentsError) {
console.error('Error fetching remaining assessments:', assessmentsError);
} else if (remainingAssessments && remainingAssessments.length > 0) {
await updateIucnAssessment(remainingAssessments[0].id, { is_latest: true });
}
}
return true;
} catch (error) {
console.error('Error in deleteIucnAssessment:', error);
throw error;
}
}
// Helper function to update latest status of IUCN assessments
async function updateIucnAssessmentsLatest(speciesId: string, latestId: string) {
try {
console.log(`Setting assessment ${latestId} as latest for species ${speciesId}`);
const { error } = await supabase
.from('iucn_assessments')
.update({ is_latest: false })
.eq('species_id', speciesId)
.neq('id', latestId);
if (error) {
console.error('Error updating IUCN assessments latest status:', error);
throw error;
}
return true;
} catch (error) {
console.error('Error in updateIucnAssessmentsLatest:', error);
throw error;
}
}

View File

@ -0,0 +1,59 @@
// Trade term validation for different taxonomic groups
// Valid trade terms for cetaceans (whales, dolphins, porpoises)
const CETACEAN_VALID_TERMS = new Set([
'teeth', // Including narwhal tusks
'meat',
'bone',
'skull',
'specimen',
'skeleton',
'skin',
'oil',
'carving',
'ivory carving', // Specifically for narwhal tusks
'tusk', // Specifically for narwhal
'live',
'body'
]);
// Mysticeti (baleen whales) specific terms
const BALEEN_WHALE_TERMS = new Set([
'baleen',
...Array.from(CETACEAN_VALID_TERMS)
]);
// Function to check if a species is a cetacean based on taxonomy
export function isCetacean(speciesData: { order_name: string; family: string }) {
return speciesData.order_name.toLowerCase() === 'cetacea';
}
// Function to check if a species is a baleen whale
export function isBaleenWhale(speciesData: { order_name: string; family: string }) {
return isCetacean(speciesData) && speciesData.family.toLowerCase() === 'balaenidae' ||
speciesData.family.toLowerCase() === 'balaenopteridae' ||
speciesData.family.toLowerCase() === 'eschrichtiidae' ||
speciesData.family.toLowerCase() === 'neobalaenidae';
}
// Function to validate trade terms for a specific species
export function validateTradeTerms(
speciesData: { order_name: string; family: string; genus: string },
term: string
): boolean {
// Convert term to lowercase for comparison
const normalizedTerm = term.toLowerCase();
// Special case for baleen whales
if (isBaleenWhale(speciesData)) {
return BALEEN_WHALE_TERMS.has(normalizedTerm);
}
// For other cetaceans (including narwhals)
if (isCetacean(speciesData)) {
return CETACEAN_VALID_TERMS.has(normalizedTerm);
}
// For other species, return true (no validation)
return true;
}