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",
|
||||
"@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",
|
||||
|
@ -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",
|
||||
|
@ -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
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
|
||||
};
|
||||
}
|
372
src/lib/api.ts
372
src/lib/api.ts
@ -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;
|
||||
}
|
||||
}
|
||||
|
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