Add remaining components: Implement tab-based species UI, CRUD operations, and supporting hooks for CITES and timeline events
This commit is contained in:
250
development.md
Normal file
250
development.md
Normal 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
23
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"@supabase/supabase-js": "^2.39.7",
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"@tanstack/react-query": "^5.24.1",
|
||||||
"@tanstack/react-query-devtools": "^5.24.1",
|
"@tanstack/react-query-devtools": "^5.24.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
@ -29,7 +30,8 @@
|
|||||||
"react-router-dom": "^6.22.1",
|
"react-router-dom": "^6.22.1",
|
||||||
"recharts": "^2.12.2",
|
"recharts": "^2.12.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.20",
|
"@types/node": "^20.11.20",
|
||||||
@ -2508,6 +2510,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
|
||||||
@ -6522,6 +6530,19 @@
|
|||||||
"base64-arraybuffer": "^1.0.2"
|
"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": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "36.9.2",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
"@supabase/supabase-js": "^2.39.7",
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"@tanstack/react-query": "^5.24.1",
|
||||||
"@tanstack/react-query-devtools": "^5.24.1",
|
"@tanstack/react-query-devtools": "^5.24.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
@ -34,7 +35,8 @@
|
|||||||
"react-router-dom": "^6.22.1",
|
"react-router-dom": "^6.22.1",
|
||||||
"recharts": "^2.12.2",
|
"recharts": "^2.12.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.20",
|
"@types/node": "^20.11.20",
|
||||||
|
@ -3,7 +3,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { FileText, Loader2 } from 'lucide-react';
|
import { FileText, Loader2 } from 'lucide-react';
|
||||||
import { SpeciesDetails } from '@/lib/api';
|
import { SpeciesDetails } from '@/lib/api';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import html2canvas from 'html2canvas';
|
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getCitesTradeRecords } from '@/lib/api';
|
import { getCitesTradeRecords } from '@/lib/api';
|
||||||
|
File diff suppressed because it is too large
Load Diff
80
src/components/species/SpeciesImage.tsx
Normal file
80
src/components/species/SpeciesImage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
257
src/components/species/tabs/CitesTab.tsx
Normal file
257
src/components/species/tabs/CitesTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
120
src/components/species/tabs/IucnTab.tsx
Normal file
120
src/components/species/tabs/IucnTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
115
src/components/species/tabs/OverviewTab.tsx
Normal file
115
src/components/species/tabs/OverviewTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
241
src/components/species/tabs/TimelineTab.tsx
Normal file
241
src/components/species/tabs/TimelineTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
173
src/hooks/useCitesListingCrud.ts
Normal file
173
src/hooks/useCitesListingCrud.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
192
src/hooks/useTimelineEventCrud.ts
Normal file
192
src/hooks/useTimelineEventCrud.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
370
src/lib/api.ts
370
src/lib/api.ts
@ -1,5 +1,6 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
import { Database } from '../types/supabase';
|
import { Database } from '../types/supabase';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export type Species = Database['public']['Tables']['species']['Row'] & {
|
export type Species = Database['public']['Tables']['species']['Row'] & {
|
||||||
common_names?: CommonName[];
|
common_names?: CommonName[];
|
||||||
@ -405,3 +406,372 @@ export async function getSpeciesImages(scientificName: string) {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
59
src/lib/trade-validation.ts
Normal file
59
src/lib/trade-validation.ts
Normal 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;
|
||||||
|
}
|
Reference in New Issue
Block a user