Compare commits
13 Commits
8197652ddc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ad12e11ed5 | |||
| b15288ef42 | |||
| e41c77a48b | |||
| cac1a70eda | |||
| d7c781d532 | |||
| 8ec303fdd2 | |||
| 6398335c7b | |||
| ab433a1d8d | |||
| 75c13df3e0 | |||
| 7c3d65dadf | |||
| c32449297a | |||
| 53481bc70e | |||
| e424f959cd |
55
.github/workflows/deploy.yml
vendored
Normal file
55
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20" # Or your preferred Node.js version
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build # This should output to 'dist' based on your package.json
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
# Upload dist folder
|
||||
path: "./dist"
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
213
data_architecture_may2025.md
Normal file
213
data_architecture_may2025.md
Normal file
@ -0,0 +1,213 @@
|
||||
# Supabase Database Schema (Arctic Species)
|
||||
|
||||
This document outlines the data architecture of the Arctic Species Supabase database.
|
||||
|
||||
## Tables
|
||||
|
||||
The database contains the following tables:
|
||||
|
||||
* `catch_records`
|
||||
* `cites_listings`
|
||||
* `cites_trade_records`
|
||||
* `common_names`
|
||||
* `conservation_measures`
|
||||
* `distribution_ranges`
|
||||
* `iucn_assessments`
|
||||
* `profiles`
|
||||
* `species`
|
||||
* `species_threats`
|
||||
* `subpopulations`
|
||||
* `timeline_events`
|
||||
|
||||
## Table Structures
|
||||
|
||||
Below is a preliminary structure for each table, inferred by querying a single record.
|
||||
|
||||
---
|
||||
|
||||
### `species`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| ----------------------- | -------------------- | ----------------------------------------- |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `scientific_name` | TEXT | Scientific name of the species |
|
||||
| `common_name` | TEXT | Common name of the species |
|
||||
| `kingdom` | TEXT | Taxonomic kingdom |
|
||||
| `phylum` | TEXT | Taxonomic phylum |
|
||||
| `class` | TEXT | Taxonomic class |
|
||||
| `order_name` | TEXT | Taxonomic order |
|
||||
| `family` | TEXT | Taxonomic family |
|
||||
| `genus` | TEXT | Taxonomic genus |
|
||||
| `species_name` | TEXT | Specific epithet |
|
||||
| `authority` | TEXT | Authority who named the species |
|
||||
| `sis_id` | INTEGER | Species Information Service ID (IUCN) |
|
||||
| `created_at` | TIMESTAMP WITH TIME ZONE | Timestamp of record creation |
|
||||
| `inaturalist_id` | INTEGER | iNaturalist taxon ID (nullable) |
|
||||
| `default_image_url` | TEXT | URL for a default image (nullable) |
|
||||
| `description` | TEXT | General description (nullable) |
|
||||
| `habitat_description` | TEXT | Description of habitat (nullable) |
|
||||
| `population_trend` | TEXT | Population trend (e.g., decreasing, stable) (nullable) |
|
||||
| `population_size` | TEXT | Estimated population size (nullable) |
|
||||
| `generation_length` | TEXT | Generation length in years (nullable) |
|
||||
| `movement_patterns` | TEXT | Description of movement patterns (nullable) |
|
||||
| `use_and_trade` | TEXT | Information on use and trade (nullable) |
|
||||
| `threats_overview` | TEXT | Overview of threats (nullable) |
|
||||
| `conservation_overview` | TEXT | Overview of conservation efforts (nullable) |
|
||||
|
||||
---
|
||||
|
||||
### `catch_records`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| ----------- | -------------------- | ----------------------------------------- |
|
||||
| `id` | INTEGER | Primary Key |
|
||||
| `species_id`| UUID | Foreign Key referencing `species.id` |
|
||||
| `country` | TEXT | Country where the catch was recorded |
|
||||
| `year` | INTEGER | Year of the catch record |
|
||||
| `area` | TEXT | Specific area of catch (nullable) |
|
||||
| `catch_total` | INTEGER | Total catch amount |
|
||||
| `quota` | INTEGER | Catch quota, if applicable (nullable) |
|
||||
| `source` | TEXT | Source of the catch data (e.g., NAMMCO) |
|
||||
| `created_at`| TIMESTAMP WITH TIME ZONE | Timestamp of record creation |
|
||||
|
||||
---
|
||||
|
||||
### `cites_listings`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| -------------- | -------------------- | ------------------------------------------- |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `species_id` | UUID | Foreign Key referencing `species.id` |
|
||||
| `appendix` | TEXT | CITES Appendix (e.g., I, II, III) |
|
||||
| `listing_date` | DATE | Date the species was listed on the appendix |
|
||||
| `notes` | TEXT | Notes regarding the CITES listing (nullable)|
|
||||
| `is_current` | BOOLEAN | Indicates if the listing is current |
|
||||
|
||||
---
|
||||
|
||||
### `cites_trade_records`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| --------------- | -------------------- | ------------------------------------------- |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `species_id` | UUID | Foreign Key referencing `species.id` |
|
||||
| `record_id` | TEXT | Unique ID for the trade record |
|
||||
| `year` | INTEGER | Year the trade occurred |
|
||||
| `appendix` | TEXT | CITES Appendix at the time of trade |
|
||||
| `taxon` | TEXT | Scientific name of the taxon in trade |
|
||||
| `class` | TEXT | Taxonomic class |
|
||||
| `order_name` | TEXT | Taxonomic order |
|
||||
| `family` | TEXT | Taxonomic family |
|
||||
| `genus` | TEXT | Taxonomic genus |
|
||||
| `term` | TEXT | Description of the traded item (e.g., skins)|
|
||||
| `quantity` | FLOAT | Quantity of the item traded |
|
||||
| `unit` | TEXT | Unit of measurement for quantity (nullable) |
|
||||
| `importer` | TEXT | Importing country code (ISO 2-letter) |
|
||||
| `exporter` | TEXT | Exporting country code (ISO 2-letter) |
|
||||
| `origin` | TEXT | Country of origin code (nullable) |
|
||||
| `purpose` | TEXT | Purpose of trade code (e.g., P for Personal)|
|
||||
| `source` | TEXT | Source of specimen code (e.g., W for Wild) |
|
||||
| `reporter_type` | TEXT | E for Exporter, I for Importer |
|
||||
| `import_permit` | TEXT | Import permit number (nullable) |
|
||||
| `export_permit` | TEXT | Export permit number (nullable) |
|
||||
| `origin_permit` | TEXT | Origin permit number (nullable) |
|
||||
|
||||
---
|
||||
|
||||
### `common_names`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| ----------- | -------------------- | ------------------------------------------- |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `species_id`| UUID | Foreign Key referencing `species.id` |
|
||||
| `name` | TEXT | Common name of the species |
|
||||
| `language` | TEXT | Language code for the common name (e.g., eng)|
|
||||
| `is_main` | BOOLEAN | Indicates if this is the primary common name|
|
||||
|
||||
---
|
||||
|
||||
### `conservation_measures`
|
||||
|
||||
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
|
||||
|
||||
---
|
||||
|
||||
### `profiles`
|
||||
|
||||
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
|
||||
|
||||
---
|
||||
|
||||
### `distribution_ranges`
|
||||
|
||||
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
|
||||
|
||||
---
|
||||
|
||||
### `iucn_assessments`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| ------------------------ | -------------------- | ------------------------------------------------------------ |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `species_id` | UUID | Foreign Key referencing `species.id` |
|
||||
| `year_published` | INTEGER | Year the IUCN assessment was published |
|
||||
| `is_latest` | BOOLEAN | Indicates if this is the latest assessment for the species |
|
||||
| `possibly_extinct` | BOOLEAN | Indicates if the species is possibly extinct |
|
||||
| `possibly_extinct_in_wild` | BOOLEAN | Indicates if the species is possibly extinct in the wild |
|
||||
| `status` | TEXT | IUCN Red List status code (e.g., LC, VU, EN) |
|
||||
| `url` | TEXT | URL to the assessment on the IUCN Red List website |
|
||||
| `assessment_id` | INTEGER | Unique ID for the assessment from IUCN |
|
||||
| `scope_code` | TEXT | Code indicating the geographic scope of the assessment |
|
||||
| `scope_description` | TEXT | Description of the geographic scope (e.g., Europe, Global) |
|
||||
|
||||
---
|
||||
|
||||
### `species_threats`
|
||||
|
||||
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
|
||||
|
||||
---
|
||||
|
||||
### `subpopulations`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| -------------------- | -------------------- | --------------------------------------------- |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `species_id` | UUID | Foreign Key referencing `species.id` |
|
||||
| `scientific_name` | TEXT | Full scientific name including subpopulation |
|
||||
| `subpopulation_name` | TEXT | Name of the subpopulation |
|
||||
| `sis_id` | INTEGER | Species Information Service ID (IUCN) |
|
||||
| `authority` | TEXT | Authority who named the subpopulation (nullable)|
|
||||
|
||||
---
|
||||
|
||||
### `timeline_events`
|
||||
|
||||
| Column Name | Data Type (Inferred) | Notes |
|
||||
| ------------- | -------------------- | ------------------------------------------------------------ |
|
||||
| `id` | UUID | Primary Key |
|
||||
| `species_id` | UUID | Foreign Key referencing `species.id` |
|
||||
| `event_date` | DATE | Date of the event |
|
||||
| `year` | INTEGER | Year of the event (can be derived from event_date) |
|
||||
| `event_type` | TEXT | Type of event (e.g., iucn_assessment, cites_listing) |
|
||||
| `title` | TEXT | Title of the event |
|
||||
| `description` | TEXT | Description of the event (nullable) |
|
||||
| `status` | TEXT | Status associated with the event (e.g., LC, Appendix II) (nullable) |
|
||||
| `source_type` | TEXT | Type of the source table (e.g., iucn_assessments) (nullable) |
|
||||
| `source_id` | UUID | Foreign Key to the source record (e.g., `iucn_assessments.id`) (nullable) |
|
||||
|
||||
---
|
||||
|
||||
## Authentication (`auth` schema)
|
||||
|
||||
Supabase provides a built-in authentication system that operates within its own `auth` schema. This schema is separate from the `public` schema detailed above but is integral to managing user identities, sessions, and access control.
|
||||
|
||||
Key tables typically found in the `auth` schema include:
|
||||
|
||||
* `users`: Stores user identity information (e.g., email, phone, password hash, user metadata). Each user is assigned a unique UUID.
|
||||
* `sessions`: Manages active user sessions.
|
||||
* `instances`: Information about authentication instances.
|
||||
* `refresh_tokens`: Stores refresh tokens for maintaining sessions.
|
||||
* `audit_log_entries`: Logs significant authentication events.
|
||||
|
||||
While these tables are managed by Supabase, the `id` from `auth.users` is commonly used as a foreign key in `public` schema tables (like `profiles`) to link user-specific data to their authentication record. For example, a `profiles` table would typically have a `user_id` column that references `auth.users.id`.
|
||||
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"**/*.config.js", // Ignoring common config files like tailwind.config.js, postcss.config.js
|
||||
"**/vite-env.d.ts", // Ignoring Vite specific type declarations
|
||||
"src/utils/supabase-admin.js" // Ignoring specific js file that seems to be a utility
|
||||
]
|
||||
},
|
||||
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], plugins: { js }, rules: js.configs.recommended.rules },
|
||||
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], languageOptions: { globals: { ...globals.browser, ...globals.node } } },
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
...pluginReact.configs.flat.recommended,
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/jsx-uses-react": "off",
|
||||
}
|
||||
}
|
||||
];
|
||||
2970
package-lock.json
generated
2970
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -3,21 +3,26 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"homepage": "https://magnussmari.github.io/arctic-species-2025-frontend",
|
||||
"homepage": "https://magnussmari.github.io/Arctic_portal_temp/",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"predeploy": "npm run build",
|
||||
"deploy": "gh-pages -d dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@supabase/supabase-js": "^2.39.7",
|
||||
"@tanstack/react-query": "^5.24.1",
|
||||
@ -32,13 +37,16 @@
|
||||
"lucide-react": "^0.338.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-router-dom": "^6.22.1",
|
||||
"recharts": "^2.12.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@types/node": "^20.11.20",
|
||||
"@types/react": "^18.2.56",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
@ -46,13 +54,16 @@
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"gh-pages": "^6.3.0",
|
||||
"globals": "^16.1.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
62
src/App.tsx
62
src/App.tsx
@ -1,14 +1,76 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './contexts/auth/AuthContext';
|
||||
import { AdminRoute } from './components/auth/AdminRoute';
|
||||
|
||||
// Public pages
|
||||
import { HomePage } from '@/pages/home';
|
||||
// import SpeciesDetail from './pages/species-detail'; // This will be created later
|
||||
|
||||
// Admin pages
|
||||
import AdminLogin from '@/pages/admin/login';
|
||||
import AdminDashboard from '@/pages/admin/dashboard';
|
||||
import SpeciesList from '@/pages/admin/species/list';
|
||||
import SpeciesEdit from '@/pages/admin/species/edit';
|
||||
import SpeciesCreate from '@/pages/admin/species/create';
|
||||
import CitesListingsList from '@/pages/admin/cites-listings/list';
|
||||
import CitesListingsEdit from '@/pages/admin/cites-listings/edit';
|
||||
import CitesListingsCreate from '@/pages/admin/cites-listings/create';
|
||||
import IucnAssessmentsList from '@/pages/admin/iucn-assessments/list';
|
||||
import IucnAssessmentsEdit from '@/pages/admin/iucn-assessments/edit';
|
||||
import IucnAssessmentsCreate from '@/pages/admin/iucn-assessments/create';
|
||||
import CommonNamesList from '@/pages/admin/common-names/list';
|
||||
import CommonNamesEdit from '@/pages/admin/common-names/edit';
|
||||
import CommonNamesCreate from '@/pages/admin/common-names/create';
|
||||
// ...other admin page imports
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* <Route path="/species/:id" element={<SpeciesDetail />} /> */}
|
||||
|
||||
{/* Admin authentication */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
|
||||
{/* Protected admin routes */}
|
||||
<Route element={<AdminRoute />}>
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
|
||||
{/* Species management */}
|
||||
<Route path="/admin/species" element={<SpeciesList />} />
|
||||
<Route path="/admin/species/create" element={<SpeciesCreate />} />
|
||||
<Route path="/admin/species/:id/edit" element={<SpeciesEdit />} />
|
||||
|
||||
{/* CITES listings management */}
|
||||
<Route path="/admin/cites-listings" element={<CitesListingsList />} />
|
||||
<Route path="/admin/cites-listings/create" element={<CitesListingsCreate />} />
|
||||
<Route path="/admin/cites-listings/:id/edit" element={<CitesListingsEdit />} />
|
||||
|
||||
{/* IUCN Assessments management */}
|
||||
<Route path="/admin/iucn-assessments" element={<IucnAssessmentsList />} />
|
||||
<Route path="/admin/iucn-assessments/create" element={<IucnAssessmentsCreate />} />
|
||||
<Route path="/admin/iucn-assessments/:id/edit" element={<IucnAssessmentsEdit />} />
|
||||
|
||||
{/* Common Names management */}
|
||||
<Route path="/admin/common-names" element={<CommonNamesList />} />
|
||||
<Route path="/admin/common-names/create" element={<CommonNamesCreate />} />
|
||||
<Route path="/admin/common-names/:id/edit" element={<CommonNamesEdit />} />
|
||||
|
||||
{/* Other admin routes... */}
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<div className="container p-8 text-center">Page not found</div>} />
|
||||
</Routes>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
207
src/components/admin/CitesListingForm.tsx
Normal file
207
src/components/admin/CitesListingForm.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CitesListing } from '@/services/adminApi';
|
||||
|
||||
// Validation schema
|
||||
const citesListingSchema = z.object({
|
||||
species_id: z.string().min(1, 'Species ID is required'),
|
||||
appendix: z.string().min(1, 'Appendix is required'),
|
||||
listing_date: z.string().min(1, 'Listing date is required'),
|
||||
notes: z.string().optional(),
|
||||
is_current: z.boolean().default(false),
|
||||
});
|
||||
|
||||
interface CitesListingFormProps {
|
||||
initialData?: CitesListing;
|
||||
onSubmit: (data: CitesListing) => void;
|
||||
isSubmitting: boolean;
|
||||
speciesId?: string; // Optional species ID for creating new listings from a species context
|
||||
}
|
||||
|
||||
export function CitesListingForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
speciesId
|
||||
}: CitesListingFormProps) {
|
||||
|
||||
// Prepare default values, using speciesId if provided and no initialData
|
||||
const defaultValues = {
|
||||
species_id: initialData?.species_id || speciesId || '',
|
||||
appendix: initialData?.appendix || '',
|
||||
listing_date: initialData?.listing_date
|
||||
? new Date(initialData.listing_date).toISOString().split('T')[0]
|
||||
: '',
|
||||
notes: initialData?.notes || '',
|
||||
is_current: initialData?.is_current || false,
|
||||
};
|
||||
|
||||
// Initialize form with react-hook-form
|
||||
const form = useForm<z.infer<typeof citesListingSchema>>({
|
||||
resolver: zodResolver(citesListingSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// Update form values if speciesId changes (useful for adding new listings from species page)
|
||||
useEffect(() => {
|
||||
if (speciesId && !initialData) {
|
||||
form.setValue('species_id', speciesId);
|
||||
}
|
||||
}, [speciesId, form, initialData]);
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof citesListingSchema>) => {
|
||||
// Convert form values to the expected data format
|
||||
const formattedData: CitesListing = {
|
||||
...values,
|
||||
// Ensure listing_date is in the correct format
|
||||
listing_date: values.listing_date,
|
||||
};
|
||||
|
||||
if (initialData?.id) {
|
||||
formattedData.id = initialData.id;
|
||||
}
|
||||
|
||||
onSubmit(formattedData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* Hidden species_id field for when provided via context */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="species_id"
|
||||
render={({ field }) => (
|
||||
<input type="hidden" {...field} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appendix"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CITES Appendix*</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select appendix" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="I">Appendix I</SelectItem>
|
||||
<SelectItem value="II">Appendix II</SelectItem>
|
||||
<SelectItem value="III">Appendix III</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The CITES appendix for this listing
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listing_date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Listing Date*</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="date"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The date when this listing came into effect
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Additional notes about this listing..."
|
||||
className="min-h-24"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional notes about the listing (annotations, exceptions, etc.)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_current"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Current Listing
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Check if this is the current active listing for the species
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={() => window.history.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : initialData ? 'Update Listing' : 'Create Listing'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
197
src/components/admin/CommonNameForm.tsx
Normal file
197
src/components/admin/CommonNameForm.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CommonName } from '@/services/adminApi';
|
||||
|
||||
// Common languages for dropdown
|
||||
const commonLanguages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'ru', label: 'Russian' },
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'zh', label: 'Chinese' },
|
||||
{ value: 'ja', label: 'Japanese' },
|
||||
{ value: 'inuktitut', label: 'Inuktitut' },
|
||||
{ value: 'greenlandic', label: 'Greenlandic' },
|
||||
{ value: 'norwegian', label: 'Norwegian' },
|
||||
{ value: 'swedish', label: 'Swedish' },
|
||||
{ value: 'finnish', label: 'Finnish' },
|
||||
{ value: 'danish', label: 'Danish' },
|
||||
{ value: 'icelandic', label: 'Icelandic' },
|
||||
{ value: 'faroese', label: 'Faroese' },
|
||||
{ value: 'sami', label: 'Sami' },
|
||||
];
|
||||
|
||||
// Validation schema
|
||||
const commonNameSchema = z.object({
|
||||
species_id: z.string().min(1, 'Species ID is required'),
|
||||
name: z.string().min(1, 'Common name is required'),
|
||||
language: z.string().min(1, 'Language is required'),
|
||||
is_main: z.boolean().default(false),
|
||||
});
|
||||
|
||||
interface CommonNameFormProps {
|
||||
initialData?: CommonName;
|
||||
onSubmit: (data: CommonName) => void;
|
||||
isSubmitting: boolean;
|
||||
speciesId?: string; // Optional species ID for creating new common names from a species context
|
||||
}
|
||||
|
||||
export function CommonNameForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
speciesId
|
||||
}: CommonNameFormProps) {
|
||||
|
||||
// Prepare default values, using speciesId if provided and no initialData
|
||||
const defaultValues = {
|
||||
species_id: initialData?.species_id || speciesId || '',
|
||||
name: initialData?.name || '',
|
||||
language: initialData?.language || 'en',
|
||||
is_main: initialData?.is_main || false,
|
||||
};
|
||||
|
||||
// Initialize form with react-hook-form
|
||||
const form = useForm<z.infer<typeof commonNameSchema>>({
|
||||
resolver: zodResolver(commonNameSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// Update form values if speciesId changes (useful for adding new common names from species page)
|
||||
useEffect(() => {
|
||||
if (speciesId && !initialData) {
|
||||
form.setValue('species_id', speciesId);
|
||||
}
|
||||
}, [speciesId, form, initialData]);
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof commonNameSchema>) => {
|
||||
// Convert form values to the expected data format
|
||||
const formattedData: CommonName = {
|
||||
...values,
|
||||
};
|
||||
|
||||
if (initialData?.id) {
|
||||
formattedData.id = initialData.id;
|
||||
}
|
||||
|
||||
onSubmit(formattedData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* Hidden species_id field for when provided via context */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="species_id"
|
||||
render={({ field }) => (
|
||||
<input type="hidden" {...field} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Common Name*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Polar Bear" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The common name in the selected language
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language*</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonLanguages.map(lang => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The language of this common name
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_main"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Main Common Name
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Check if this is the main common name for this species in this language
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={() => window.history.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : initialData ? 'Update Common Name' : 'Create Common Name'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
433
src/components/admin/IucnAssessmentForm.tsx
Normal file
433
src/components/admin/IucnAssessmentForm.tsx
Normal file
@ -0,0 +1,433 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { IucnAssessment } from '@/services/adminApi';
|
||||
|
||||
// Validation schema
|
||||
const iucnAssessmentSchema = z.object({
|
||||
species_id: z.string().min(1, 'Species ID is required'),
|
||||
year_published: z.coerce.number().int().min(1900, 'Year must be at least 1900'),
|
||||
status: z.string().min(1, 'Status is required'),
|
||||
is_latest: z.boolean().default(false),
|
||||
possibly_extinct: z.boolean().default(false),
|
||||
possibly_extinct_in_wild: z.boolean().default(false),
|
||||
url: z.string().url().optional().or(z.string().length(0)),
|
||||
assessment_id: z.coerce.number().int().positive().optional(),
|
||||
scope_code: z.string().optional(),
|
||||
scope_description: z.string().optional(),
|
||||
// Add the following fields:
|
||||
red_list_criteria: z.string().optional(),
|
||||
year_assessed: z.coerce.number().int().min(1900, 'Year must be at least 1900').optional().nullable(),
|
||||
population_trend: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// IUCN Status options with colors
|
||||
const iucnStatusOptions = [
|
||||
{ value: 'EX', label: 'Extinct', color: '#000000' },
|
||||
{ value: 'EW', label: 'Extinct in the Wild', color: '#542344' },
|
||||
{ value: 'CR', label: 'Critically Endangered', color: '#D81E05' },
|
||||
{ value: 'EN', label: 'Endangered', color: '#FC7F3F' },
|
||||
{ value: 'VU', label: 'Vulnerable', color: '#F9E814' },
|
||||
{ value: 'NT', label: 'Near Threatened', color: '#CCE226' },
|
||||
{ value: 'LC', label: 'Least Concern', color: '#60C659' },
|
||||
{ value: 'DD', label: 'Data Deficient', color: '#D1D1C6' },
|
||||
{ value: 'NE', label: 'Not Evaluated', color: '#FFFFFF' },
|
||||
];
|
||||
|
||||
interface IucnAssessmentFormProps {
|
||||
initialData?: IucnAssessment;
|
||||
onSubmit: (data: IucnAssessment) => void;
|
||||
isSubmitting: boolean;
|
||||
speciesId?: string; // Optional species ID for creating new assessments from a species context
|
||||
}
|
||||
|
||||
export function IucnAssessmentForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
speciesId
|
||||
}: IucnAssessmentFormProps) {
|
||||
|
||||
// Get current year for max validation
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Prepare default values, using speciesId if provided and no initialData
|
||||
const defaultValues = {
|
||||
species_id: initialData?.species_id || speciesId || '',
|
||||
year_published: initialData?.year_published || currentYear,
|
||||
status: initialData?.status || 'NE',
|
||||
is_latest: initialData?.is_latest || false,
|
||||
possibly_extinct: initialData?.possibly_extinct || false,
|
||||
possibly_extinct_in_wild: initialData?.possibly_extinct_in_wild || false,
|
||||
url: initialData?.url || '',
|
||||
assessment_id: initialData?.assessment_id || undefined,
|
||||
scope_code: initialData?.scope_code || '',
|
||||
scope_description: initialData?.scope_description || '',
|
||||
};
|
||||
|
||||
// Initialize form with react-hook-form
|
||||
const form = useForm<z.infer<typeof iucnAssessmentSchema>>({
|
||||
resolver: zodResolver(iucnAssessmentSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// Update form values if speciesId changes (useful for adding new assessments from species page)
|
||||
useEffect(() => {
|
||||
if (speciesId && !initialData) {
|
||||
form.setValue('species_id', speciesId);
|
||||
}
|
||||
}, [speciesId, form, initialData]);
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof iucnAssessmentSchema>) => {
|
||||
// Convert form values to the expected data format
|
||||
const formattedData: IucnAssessment = {
|
||||
...values,
|
||||
year_published: Number(values.year_published),
|
||||
assessment_id: values.assessment_id ? Number(values.assessment_id) : undefined,
|
||||
};
|
||||
|
||||
if (initialData?.id) {
|
||||
formattedData.id = initialData.id;
|
||||
}
|
||||
|
||||
onSubmit(formattedData);
|
||||
};
|
||||
|
||||
// Get current status to show extinction checkboxes only when appropriate
|
||||
const currentStatus = form.watch('status');
|
||||
const showExtinctionFlags = ['CR', 'EN', 'VU'].includes(currentStatus);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* Hidden species_id field for when provided via context */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="species_id"
|
||||
render={({ field }) => (
|
||||
<input type="hidden" {...field} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IUCN Status*</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{iucnStatusOptions.map(status => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="mr-2 inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: status.color }}
|
||||
/>
|
||||
{status.label} ({status.value})
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the IUCN status for this assessment. You can find more information about IUCN Red List Categories and Criteria <a href="https://www.iucnredlist.org/resources/categories-and-criteria" target="_blank" rel="noopener noreferrer">here</a>.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="year_published"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Year Published*</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1900}
|
||||
max={currentYear}
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The year this assessment was published
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assessment_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Assessment ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The IUCN's assessment ID (if available)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Assessment URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="https://www.iucnredlist.org/species/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
URL to the IUCN Red List assessment
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scope_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scope Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Assessment scope code (if applicable)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scope_description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scope Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_latest"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Latest Assessment
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Check if this is the most recent assessment for the species
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showExtinctionFlags && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="possibly_extinct"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Possibly Extinct
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Species is possibly already extinct, but confirmation is needed
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="possibly_extinct_in_wild"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Possibly Extinct in the Wild
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Species is possibly already extinct in the wild, but may exist in captivity
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="red_list_criteria"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Red List Criteria</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter Red List Criteria (e.g. A1abc)" {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the specific criteria met for the assessment (e.g., A1abc). If there are multiple criteria, you can list them separated by commas.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="year_assessed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Year Assessed</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="YYYY" {...field} onChange={e => field.onChange(parseInt(e.target.value, 10) || null)} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the year the assessment was conducted.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="population_trend"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Population Trend</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value || undefined} value={field.value || undefined}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select population trend" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="stable">Stable</SelectItem>
|
||||
<SelectItem value="decreasing">Decreasing</SelectItem>
|
||||
<SelectItem value="increasing">Increasing</SelectItem>
|
||||
<SelectItem value="unknown">Unknown</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the current population trend for the species.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Add any notes about the assessment" {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide any additional notes or comments regarding this IUCN assessment. This could include information on data quality, specific threats, or conservation actions. It's a good place to elaborate on any nuances not captured by the structured fields.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={() => window.history.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : initialData ? 'Update Assessment' : 'Create Assessment'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
628
src/components/admin/SpeciesForm.tsx
Normal file
628
src/components/admin/SpeciesForm.tsx
Normal file
@ -0,0 +1,628 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Species } from '@/services/adminApi';
|
||||
|
||||
// Validation schema
|
||||
const speciesSchema = z.object({
|
||||
scientific_name: z.string().min(1, 'Scientific name is required'),
|
||||
common_name: z.string().min(1, 'Common name is required'),
|
||||
kingdom: z.string().min(1, 'Kingdom is required'),
|
||||
phylum: z.string().min(1, 'Phylum is required'),
|
||||
class: z.string().min(1, 'Class is required'),
|
||||
order_name: z.string().min(1, 'Order is required'),
|
||||
family: z.string().min(1, 'Family is required'),
|
||||
genus: z.string().min(1, 'Genus is required'),
|
||||
species_name: z.string().min(1, 'Species is required'),
|
||||
authority: z.string(), // No longer optional, allows empty string
|
||||
sis_id: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().int().positive().optional()),
|
||||
inaturalist_id: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().int().positive().optional()),
|
||||
default_image_url: z.string().url().optional().or(z.string().length(0)),
|
||||
description: z.string().optional(),
|
||||
habitat_description: z.string().optional(),
|
||||
population_trend: z.string().optional(),
|
||||
population_size: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().optional()),
|
||||
generation_length: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().optional()),
|
||||
movement_patterns: z.string().optional(),
|
||||
use_and_trade: z.string().optional(),
|
||||
threats_overview: z.string().optional(),
|
||||
conservation_overview: z.string().optional(),
|
||||
});
|
||||
|
||||
interface SpeciesFormProps {
|
||||
initialData?: Species;
|
||||
onSubmit: (data: Species) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function SpeciesForm({ initialData, onSubmit, isSubmitting }: SpeciesFormProps) {
|
||||
// Start with the description tab if the species has a description
|
||||
const initialTab = initialData?.description ? 'description' : 'basic';
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
|
||||
// Prepare default values, converting nulls from initialData to empty strings
|
||||
// for optional fields to align with Zod's expectation (string | undefined)
|
||||
const getSafeDefaultValue = (value: string | null | undefined) => value === null ? '' : value;
|
||||
const getSafeNumericDefaultValue = (value: number | string | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? undefined : num;
|
||||
};
|
||||
|
||||
const form = useForm<z.infer<typeof speciesSchema>>({
|
||||
resolver: zodResolver(speciesSchema),
|
||||
defaultValues: initialData
|
||||
? {
|
||||
...initialData,
|
||||
authority: getSafeDefaultValue(initialData.authority),
|
||||
default_image_url: getSafeDefaultValue(initialData.default_image_url),
|
||||
description: getSafeDefaultValue(initialData.description),
|
||||
habitat_description: getSafeDefaultValue(initialData.habitat_description),
|
||||
population_trend: getSafeDefaultValue(initialData.population_trend),
|
||||
// For numeric fields, ensure they are numbers or undefined
|
||||
sis_id: getSafeNumericDefaultValue(initialData.sis_id),
|
||||
inaturalist_id: getSafeNumericDefaultValue(initialData.inaturalist_id),
|
||||
population_size: getSafeNumericDefaultValue(initialData.population_size),
|
||||
generation_length: getSafeNumericDefaultValue(initialData.generation_length),
|
||||
movement_patterns: getSafeDefaultValue(initialData.movement_patterns),
|
||||
use_and_trade: getSafeDefaultValue(initialData.use_and_trade),
|
||||
threats_overview: getSafeDefaultValue(initialData.threats_overview),
|
||||
conservation_overview: getSafeDefaultValue(initialData.conservation_overview),
|
||||
}
|
||||
: {
|
||||
scientific_name: '',
|
||||
common_name: '',
|
||||
kingdom: 'Animalia',
|
||||
phylum: '',
|
||||
class: '',
|
||||
order_name: '',
|
||||
family: '',
|
||||
genus: '',
|
||||
species_name: '',
|
||||
authority: '',
|
||||
sis_id: undefined,
|
||||
inaturalist_id: undefined,
|
||||
default_image_url: '',
|
||||
description: '',
|
||||
habitat_description: '',
|
||||
population_trend: '', // Default to empty string, Select handles it
|
||||
population_size: undefined,
|
||||
generation_length: undefined,
|
||||
movement_patterns: '',
|
||||
use_and_trade: '',
|
||||
threats_overview: '',
|
||||
conservation_overview: '',
|
||||
},
|
||||
});
|
||||
|
||||
// If initialData changes (e.g., after a fetch), reset the form with new processed defaults.
|
||||
// This is important if the component re-renders with new initialData after the first mount.
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
form.reset({
|
||||
...initialData,
|
||||
authority: getSafeDefaultValue(initialData.authority),
|
||||
default_image_url: getSafeDefaultValue(initialData.default_image_url),
|
||||
description: getSafeDefaultValue(initialData.description),
|
||||
habitat_description: getSafeDefaultValue(initialData.habitat_description),
|
||||
population_trend: getSafeDefaultValue(initialData.population_trend),
|
||||
sis_id: getSafeNumericDefaultValue(initialData.sis_id),
|
||||
inaturalist_id: getSafeNumericDefaultValue(initialData.inaturalist_id),
|
||||
population_size: getSafeNumericDefaultValue(initialData.population_size),
|
||||
generation_length: getSafeNumericDefaultValue(initialData.generation_length),
|
||||
movement_patterns: getSafeDefaultValue(initialData.movement_patterns),
|
||||
use_and_trade: getSafeDefaultValue(initialData.use_and_trade),
|
||||
threats_overview: getSafeDefaultValue(initialData.threats_overview),
|
||||
conservation_overview: getSafeDefaultValue(initialData.conservation_overview),
|
||||
});
|
||||
}
|
||||
}, [initialData, form.reset]); // form.reset is a stable function reference
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof speciesSchema>) => {
|
||||
// Process values to ensure optional empty strings are sent as empty strings,
|
||||
// or convert to null if your backend prefers null for empty optional text fields.
|
||||
// The current Zod schema and Supabase text fields generally handle empty strings fine.
|
||||
const processedValues = {
|
||||
...values,
|
||||
description: values.description || '',
|
||||
habitat_description: values.habitat_description || '',
|
||||
population_trend: values.population_trend || '', // Keep as empty string if that's what select gives
|
||||
movement_patterns: values.movement_patterns || '',
|
||||
use_and_trade: values.use_and_trade || '',
|
||||
threats_overview: values.threats_overview || '',
|
||||
conservation_overview: values.conservation_overview || '',
|
||||
// Ensure numeric fields are sent as null if undefined, or their numeric value
|
||||
sis_id: values.sis_id === undefined ? null : values.sis_id,
|
||||
inaturalist_id: values.inaturalist_id === undefined ? null : values.inaturalist_id,
|
||||
population_size: values.population_size === undefined ? null : values.population_size,
|
||||
generation_length: values.generation_length === undefined ? null : values.generation_length,
|
||||
};
|
||||
onSubmit(processedValues as Species);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
|
||||
<TabsTrigger value="basic">Basic Info</TabsTrigger>
|
||||
<TabsTrigger value="taxonomy">Taxonomy</TabsTrigger>
|
||||
<TabsTrigger value="description">Description</TabsTrigger>
|
||||
<TabsTrigger value="conservation">Conservation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Basic Info Tab */}
|
||||
<TabsContent value="basic" className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scientific_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scientific Name*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Ursus maritimus" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Full scientific name with genus and species
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="common_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Common Name*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Polar Bear" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Primary English common name
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sis_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IUCN SIS ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
|
||||
placeholder="e.g. 22823"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Species Information Service ID from IUCN
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="inaturalist_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>iNaturalist ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
|
||||
placeholder="e.g. 42021"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Taxon ID from iNaturalist
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="default_image_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/image.jpg" value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
URL for the main species image
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Taxonomy Tab */}
|
||||
<TabsContent value="taxonomy" className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="kingdom"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Kingdom*</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select kingdom" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Animalia">Animalia</SelectItem>
|
||||
<SelectItem value="Plantae">Plantae</SelectItem>
|
||||
<SelectItem value="Fungi">Fungi</SelectItem>
|
||||
<SelectItem value="Protista">Protista</SelectItem>
|
||||
<SelectItem value="Chromista">Chromista</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phylum"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phylum*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Chordata" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="class"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Class*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Mammalia" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="order_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Order*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Carnivora" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="family"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Family*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Ursidae" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="genus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Genus*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Ursus" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="species_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Species*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. maritimus" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Specific epithet (species part of the binomial name)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authority</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Phipps, 1774" value={field.value || ''}/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Name and year of the authority who described the species
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Description Tab */}
|
||||
<TabsContent value="description" className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>General Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="General description of the species..."
|
||||
className="min-h-64"
|
||||
rows={12}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="habitat_description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Habitat Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="Description of the species' habitat..."
|
||||
className="min-h-64"
|
||||
rows={10}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="population_trend"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Population Trend</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || 'unknown'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select trend" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="unknown">Unknown</SelectItem>
|
||||
<SelectItem value="increasing">Increasing</SelectItem>
|
||||
<SelectItem value="stable">Stable</SelectItem>
|
||||
<SelectItem value="decreasing">Decreasing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="population_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Population Size</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
value={field.value === undefined || field.value === null ? '' : String(field.value)}
|
||||
onChange={e => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
|
||||
placeholder="e.g. 25000"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="generation_length"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Generation Length</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="any" // Allows decimal input
|
||||
{...field}
|
||||
value={field.value === undefined || field.value === null ? '' : String(field.value)}
|
||||
onChange={e => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
|
||||
placeholder="e.g. 12.3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="movement_patterns"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Movement Patterns</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. Migratory/Resident" value={field.value || ''}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Conservation Tab */}
|
||||
<TabsContent value="conservation" className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_and_trade"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use and Trade</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="Information on use and trade of this species..."
|
||||
className="min-h-64"
|
||||
rows={10}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="threats_overview"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Threats Overview</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="Overview of threats to this species..."
|
||||
className="min-h-64"
|
||||
rows={10}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="conservation_overview"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Conservation Overview</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="Overview of conservation efforts for this species..."
|
||||
className="min-h-64"
|
||||
rows={10}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={() => window.history.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : initialData ? 'Update Species' : 'Create Species'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
16
src/components/auth/AdminRoute.tsx
Normal file
16
src/components/auth/AdminRoute.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/auth/AuthContext';
|
||||
|
||||
export function AdminRoute() {
|
||||
const { user, loading, isAdmin } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-screen items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user || !isAdmin) {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
70
src/components/auth/LoginForm.tsx
Normal file
70
src/components/auth/LoginForm.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/auth/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
export function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await signIn(email, password);
|
||||
} catch {
|
||||
setError('Invalid email or password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-6 rounded-lg border p-8 shadow-md">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-3xl font-bold">Admin Login</h1>
|
||||
<p className="text-muted-foreground">Enter your credentials to access the admin panel</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">Email</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
src/components/compare-trade-tab.tsx
Normal file
281
src/components/compare-trade-tab.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { getAllSpecies, getCitesTradeRecords } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
// TODO: Implement a better multi-select component (e.g., Shadcn Combobox with multi-select)
|
||||
// For now, using a simple list with checkboxes as a placeholder concept
|
||||
|
||||
// Helper function to format numbers without decimals
|
||||
const formatNumber = (value: number) => Math.round(value).toLocaleString();
|
||||
|
||||
// Helper function to generate distinct colors for chart lines
|
||||
function generateColor(index: number): string {
|
||||
const colors = [
|
||||
"#8884d8", "#82ca9d", "#ffc658", "#ff7300", "#0088FE",
|
||||
"#00C49F", "#FFBB28", "#FF8042", "#d0ed57", "#ffc0cb"
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
export function CompareTradeTab() {
|
||||
const [selectedSpeciesIds, setSelectedSpeciesIds] = useState<string[]>([]);
|
||||
// State to trigger loading
|
||||
const [loadTriggered, setLoadTriggered] = useState<boolean>(false);
|
||||
|
||||
// 1. Fetch all species for the selector
|
||||
const { data: allSpecies, isLoading: isLoadingAllSpecies, error: errorAllSpecies } = useQuery({
|
||||
queryKey: ['allSpecies'],
|
||||
queryFn: getAllSpecies,
|
||||
});
|
||||
|
||||
// 2. Fetch trade data for selected species using useQueries
|
||||
const tradeDataQueries = useQueries({
|
||||
queries: selectedSpeciesIds.map(id => ({
|
||||
queryKey: ['citesTradeRecords', id],
|
||||
queryFn: () => getCitesTradeRecords(id),
|
||||
// Enable only when >= 2 species selected AND button clicked
|
||||
enabled: !!id && selectedSpeciesIds.length >= 2 && loadTriggered,
|
||||
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
|
||||
})),
|
||||
});
|
||||
|
||||
// Check collective loading/error states
|
||||
const isLoadingTradeData = loadTriggered && tradeDataQueries.some(query => query.isLoading);
|
||||
const errorTradeData = loadTriggered ? tradeDataQueries.find(query => query.isError)?.error : null;
|
||||
|
||||
// 3. Process fetched data for the chart AND calculate totals
|
||||
const { chartEntries, speciesTotals } = useMemo(() => {
|
||||
const result = {
|
||||
chartEntries: [] as { year: number; [speciesId: string]: number }[],
|
||||
speciesTotals: {} as Record<string, number>
|
||||
};
|
||||
|
||||
if (!loadTriggered || isLoadingTradeData || errorTradeData || selectedSpeciesIds.length < 2) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const allDataLoaded = tradeDataQueries.every(q => q.isSuccess && q.data);
|
||||
if (!allDataLoaded) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const yearlyData: Record<number, { year: number; [speciesId: string]: number }> = {};
|
||||
const totals: Record<string, number> = {};
|
||||
|
||||
tradeDataQueries.forEach((query, index) => {
|
||||
const speciesId = selectedSpeciesIds[index];
|
||||
totals[speciesId] = 0; // Initialize total for this species
|
||||
if (query.isSuccess && query.data) {
|
||||
query.data.forEach(record => {
|
||||
const year = record.year;
|
||||
const quantity = Number(record.quantity) || 0;
|
||||
|
||||
if (!yearlyData[year]) {
|
||||
yearlyData[year] = { year };
|
||||
}
|
||||
if (yearlyData[year][speciesId] === undefined) {
|
||||
yearlyData[year][speciesId] = 0;
|
||||
}
|
||||
yearlyData[year][speciesId] += quantity;
|
||||
totals[speciesId] += quantity; // Accumulate total
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allYears = Object.keys(yearlyData).map(Number).sort((a,b) => a - b);
|
||||
result.chartEntries = allYears.map(year => {
|
||||
const yearEntry = yearlyData[year];
|
||||
selectedSpeciesIds.forEach(id => {
|
||||
if(yearEntry[id] === undefined) {
|
||||
yearEntry[id] = 0;
|
||||
}
|
||||
});
|
||||
return yearEntry;
|
||||
});
|
||||
result.speciesTotals = totals;
|
||||
|
||||
return result;
|
||||
|
||||
}, [tradeDataQueries, selectedSpeciesIds, isLoadingTradeData, errorTradeData, loadTriggered]);
|
||||
|
||||
const handleSelectChange = (speciesId: string) => {
|
||||
setSelectedSpeciesIds(prev =>
|
||||
prev.includes(speciesId)
|
||||
? prev.filter(id => id !== speciesId)
|
||||
: [...prev, speciesId]
|
||||
);
|
||||
setLoadTriggered(false); // Reset trigger when selection changes
|
||||
};
|
||||
|
||||
// Handler for Load button
|
||||
const handleLoadComparison = () => {
|
||||
if (selectedSpeciesIds.length >= 2) {
|
||||
setLoadTriggered(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for Clear button
|
||||
const handleClearSelections = () => {
|
||||
setSelectedSpeciesIds([]);
|
||||
setLoadTriggered(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Compare CITES Trade Data</CardTitle>
|
||||
<CardDescription>Select two or more species, then click "Load Comparison".</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Species Selector Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">1. Select Species</h3>
|
||||
{isLoadingAllSpecies && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
{errorAllSpecies && <p className="text-destructive">Error loading species list.</p>}
|
||||
{allSpecies && (
|
||||
// Placeholder Selector - Replace with proper multi-select Combobox later
|
||||
<div className="max-h-60 overflow-y-auto border rounded-md p-4 space-y-2">
|
||||
{allSpecies.map((species) => (
|
||||
<div key={species.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`compare-${species.id}`}
|
||||
checked={selectedSpeciesIds.includes(species.id)}
|
||||
onCheckedChange={() => {
|
||||
handleSelectChange(species.id);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`compare-${species.id}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{species.primary_common_name || species.scientific_name} (<span className="italic">{species.scientific_name}</span>)
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
onClick={handleLoadComparison}
|
||||
disabled={selectedSpeciesIds.length < 2}
|
||||
>
|
||||
Load Comparison
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearSelections}
|
||||
disabled={selectedSpeciesIds.length === 0}
|
||||
>
|
||||
Clear Selections
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
{/* Show loading state only when load is triggered */}
|
||||
{loadTriggered && isLoadingTradeData && (
|
||||
<div className="flex items-center justify-center py-10 border-t pt-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin mr-3" />
|
||||
<span className="text-lg">Loading comparison data...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Show error state only when load is triggered */}
|
||||
{loadTriggered && errorTradeData && (
|
||||
<div className="text-destructive text-center py-10 border-t pt-6">
|
||||
Error loading trade data: {(errorTradeData as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart Section - Show only if load triggered, not loading, no error, and >= 2 selected */}
|
||||
{loadTriggered && !isLoadingTradeData && !errorTradeData && selectedSpeciesIds.length >= 2 && (
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-xl font-semibold mb-1">2. Trade Volume Comparison (Reported Quantity)</h3>
|
||||
{/* Add Data Source Description */}
|
||||
<p className="text-sm text-muted-foreground mb-4">Data source: CITES Trade Database (March 2025 release)</p>
|
||||
|
||||
{chartEntries.length > 0 ? (
|
||||
<div className="h-96 mb-6">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartEntries}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis tickFormatter={formatNumber} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatNumber(Number(value || 0)),
|
||||
name || 'Quantity'
|
||||
]}
|
||||
labelFormatter={(label) => `Year: ${label}`}
|
||||
/>
|
||||
<Legend />
|
||||
{selectedSpeciesIds.map((id, index) => {
|
||||
const speciesName = allSpecies?.find(s => s.id === id)?.primary_common_name || allSpecies?.find(s => s.id === id)?.scientific_name || id;
|
||||
return (
|
||||
<Line
|
||||
key={id}
|
||||
type="monotone"
|
||||
dataKey={id as string}
|
||||
stroke={generateColor(index)}
|
||||
name={speciesName}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center pt-8">No comparable trade data found for the selected species combination in the available years.</p>
|
||||
)}
|
||||
|
||||
{/* Summary Section (Show only if chart data exists) */}
|
||||
{chartEntries.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">Total Reported Quantity (Selected Period)</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedSpeciesIds.map((id, index) => {
|
||||
const speciesName = allSpecies?.find(s => s.id === id)?.primary_common_name || allSpecies?.find(s => s.id === id)?.scientific_name || id;
|
||||
const total = speciesTotals[id] ?? 0;
|
||||
return (
|
||||
<div key={id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: generateColor(index) }}
|
||||
></span>
|
||||
<span>{speciesName}</span>
|
||||
</div>
|
||||
<span className="font-medium">{formatNumber(total)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial prompt or prompt after clearing selections */}
|
||||
{!loadTriggered && selectedSpeciesIds.length < 2 && (
|
||||
<p className="text-muted-foreground text-center pt-8 border-t mt-6">Select two or more species and click "Load Comparison".</p>
|
||||
)}
|
||||
{!loadTriggered && selectedSpeciesIds.length >= 2 && (
|
||||
<p className="text-muted-foreground text-center pt-8 border-t mt-6">Click "Load Comparison" to view the chart.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type DebugPanelProps = {
|
||||
data: any;
|
||||
data: Record<string, unknown>;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
@ -11,13 +11,13 @@ export function DebugPanel({ data, title = 'Debug Data' }: DebugPanelProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Extract CITES listings if they exist
|
||||
const citesListings = data?.cites_listings || [];
|
||||
const citesListings = Array.isArray(data?.cites_listings) ? data.cites_listings : [];
|
||||
|
||||
return (
|
||||
<Card className="border-dashed border-gray-300">
|
||||
<CardHeader className="py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-normal text-muted-foreground">
|
||||
<CardTitle className="text-base font-normal text-muted-foreground">
|
||||
{title} {citesListings.length > 0 && `(${citesListings.length} CITES listings)`}
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsVisible(!isVisible)}>
|
||||
@ -29,13 +29,13 @@ export function DebugPanel({ data, title = 'Debug Data' }: DebugPanelProps) {
|
||||
<CardContent className="pb-3 pt-0">
|
||||
{citesListings.length > 0 && (
|
||||
<div className="mb-4 rounded border border-yellow-200 bg-yellow-50 p-2">
|
||||
<h3 className="mb-2 text-sm font-semibold text-yellow-800">CITES Listings:</h3>
|
||||
<pre className="overflow-auto text-xs text-yellow-900">
|
||||
<h3 className="mb-2 text-base font-semibold text-yellow-800">CITES Listings:</h3>
|
||||
<pre className="overflow-auto text-sm text-yellow-900">
|
||||
{JSON.stringify(citesListings, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<pre className="max-h-96 overflow-auto rounded bg-slate-50 p-2 text-xs">
|
||||
<pre className="max-h-96 overflow-auto rounded bg-slate-50 p-2 text-sm">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
|
||||
104
src/components/layout/AdminLayout.tsx
Normal file
104
src/components/layout/AdminLayout.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/auth/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
BarChart,
|
||||
BookOpen,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Menu,
|
||||
Tag,
|
||||
User,
|
||||
LogOut,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { Toaster } from '@/components/ui/toaster'; // Import Toaster
|
||||
|
||||
const sidebarItems = [
|
||||
{ name: 'Dashboard', path: '/admin', icon: <BarChart size={20} /> },
|
||||
{ name: 'Species', path: '/admin/species', icon: <BookOpen size={20} /> },
|
||||
{ name: 'CITES Listings', path: '/admin/cites-listings', icon: <Tag size={20} /> },
|
||||
{ name: 'IUCN Assessments', path: '/admin/iucn-assessments', icon: <ListChecks size={20} /> },
|
||||
{ name: 'Common Names', path: '/admin/common-names', icon: <Layers size={20} /> },
|
||||
// Add other items as needed
|
||||
];
|
||||
|
||||
export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { signOut, user } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col md:flex-row">
|
||||
{/* Mobile sidebar toggle */}
|
||||
<button
|
||||
className="absolute right-4 top-4 md:hidden"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`w-64 bg-slate-900 text-white transition-all ${
|
||||
sidebarOpen ? 'fixed inset-y-0 left-0 z-50' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<div className="mb-8 mt-4 text-center">
|
||||
<h1 className="text-xl font-bold">Arctic Species Admin</h1>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{sidebarItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center rounded-md p-3 transition-colors ${
|
||||
location.pathname === item.path
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="ml-3">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto space-y-2 pt-6">
|
||||
<div className="flex items-center space-x-3 rounded-md bg-slate-800 p-3 text-white">
|
||||
<User size={20} />
|
||||
<div className="overflow-hidden text-sm">
|
||||
<p className="truncate font-medium">{user?.email}</p>
|
||||
<p className="text-xs text-slate-400">Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut size={20} className="mr-3" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<main className="container mx-auto p-6">{children}</main>
|
||||
<Toaster /> {/* Add Toaster here */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,113 +1,130 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSpeciesById } from '@/lib/api';
|
||||
import { SpeciesTabs } from './species-tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import {
|
||||
getSpeciesById,
|
||||
type SpeciesDetails
|
||||
} from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IUCN_STATUS_COLORS, CITES_APPENDIX_COLORS } from '@/lib/utils';
|
||||
import { Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { DebugPanel } from './debug-panel';
|
||||
import { useEffect } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { SpeciesTabs } from './species-tabs';
|
||||
|
||||
type ResultsContainerProps = {
|
||||
interface ResultsContainerProps {
|
||||
speciesId: string | null;
|
||||
onBack: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function ResultsContainer({ speciesId, onBack }: ResultsContainerProps) {
|
||||
const { data: species, isLoading, error } = useQuery({
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: species, isLoading: isLoadingSpecies, error: errorSpecies } = useQuery<SpeciesDetails | null, Error>({
|
||||
queryKey: ['species', speciesId],
|
||||
queryFn: () => getSpeciesById(speciesId as string),
|
||||
queryFn: () => speciesId ? getSpeciesById(speciesId) : Promise.resolve(null),
|
||||
enabled: !!speciesId,
|
||||
});
|
||||
|
||||
// Add this useEffect to see the full species data in console when it changes
|
||||
useEffect(() => {
|
||||
if (species) {
|
||||
console.log('ResultsContainer species data loaded for ID:', species.id);
|
||||
console.log('CITES listings in ResultsContainer:', species.cites_listings?.length);
|
||||
|
||||
if (species.cites_listings?.length > 0) {
|
||||
console.log('First CITES listing:', species.cites_listings[0]);
|
||||
|
||||
if (species.cites_listings.length > 1) {
|
||||
console.log('Second CITES listing:', species.cites_listings[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [species]);
|
||||
|
||||
if (!speciesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoadingSpecies) {
|
||||
return (
|
||||
<div className="flex h-64 w-full items-center justify-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<Loader2 className="h-16 w-16 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !species) {
|
||||
if (errorSpecies || !species) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<div className="container mx-auto p-8">
|
||||
<Card className="w-full max-w-lg mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Error Loading Species</CardTitle>
|
||||
<CardTitle className="text-destructive">
|
||||
{errorSpecies ? 'Error Loading Species' : 'Species Not Found'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">There was an error loading the species information. Please try again.</p>
|
||||
<p className="mb-4">
|
||||
{errorSpecies
|
||||
? 'There was an error loading the species information.'
|
||||
: `Could not find details for species ID: ${speciesId}`}
|
||||
</p>
|
||||
{errorSpecies && (
|
||||
<p className="text-sm text-muted-foreground mb-4">{(errorSpecies as Error)?.message}</p>
|
||||
)}
|
||||
<Button onClick={onBack} variant="outline" size="sm">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Search
|
||||
<ChevronLeft className="mr-2 h-5 w-5" />
|
||||
Back to Species List
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'CR': return 'bg-red-600 text-white';
|
||||
case 'EN': return 'bg-orange-600 text-white';
|
||||
case 'VU': return 'bg-yellow-600 text-black';
|
||||
case 'NT': return 'bg-blue-600 text-white';
|
||||
case 'LC': return 'bg-green-600 text-white';
|
||||
default: return 'bg-gray-600 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
const getAppendixColor = (appendix: string): string => {
|
||||
switch (appendix) {
|
||||
case 'I': return 'bg-red-600 text-white';
|
||||
case 'II': return 'bg-blue-600 text-white';
|
||||
case 'III': return 'bg-yellow-600 text-black';
|
||||
default: return 'bg-gray-600 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<Button onClick={onBack} variant="outline" size="sm">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Search
|
||||
<div className="w-full bg-white dark:bg-gray-950 min-h-screen">
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="bg-gradient-to-r from-blue-900 to-blue-600 py-10 md:py-14"
|
||||
>
|
||||
<div className="container-fluid w-full px-6">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mb-6 bg-white/10 text-white hover:bg-white/20 backdrop-blur-sm border-white/30"
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-5 w-5" />
|
||||
Back to Species List
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
<div className="text-center text-white">
|
||||
<h1 className="text-4xl md:text-5xl font-bold italic mb-3">{species.scientific_name}</h1>
|
||||
<p className="text-xl md:text-2xl text-blue-100 mb-5">
|
||||
{species.common_names[0]?.name || 'No common name available'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{species.latest_assessment?.status && (
|
||||
<Badge className={IUCN_STATUS_COLORS[species.latest_assessment.status]}>
|
||||
<Badge className={`text-sm md:text-base px-4 md:px-6 py-2 rounded-full ${getStatusColor(species.latest_assessment.status)}`}>
|
||||
IUCN: {species.latest_assessment.status}
|
||||
</Badge>
|
||||
)}
|
||||
{species.current_cites_listing && (
|
||||
<Badge className={CITES_APPENDIX_COLORS[species.current_cites_listing.appendix]}>
|
||||
<Badge className={`text-sm md:text-base px-4 md:px-6 py-2 rounded-full ${getAppendixColor(species.current_cites_listing.appendix)}`}>
|
||||
CITES: Appendix {species.current_cites_listing.appendix}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-center gap-2">
|
||||
<span className="italic">{species.scientific_name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{species.common_name}
|
||||
{species.common_names?.length > 0 && (
|
||||
<span className="ml-2 text-xs">
|
||||
(Also known as: {species.common_names.map((cn) => cn.name).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="container-fluid w-full px-6 py-8">
|
||||
<SpeciesTabs species={species} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Debug panel to see data */}
|
||||
<DebugPanel data={species} title="Species Data from Supabase" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
706
src/components/results-container.tsx.bak
Normal file
706
src/components/results-container.tsx.bak
Normal file
@ -0,0 +1,706 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getSpeciesById,
|
||||
getSpeciesImages,
|
||||
getSpeciesDistribution,
|
||||
getSpeciesThreats,
|
||||
getConservationMeasures,
|
||||
getSpeciesExtendedInfo,
|
||||
getTimelineEvents
|
||||
} from '@/lib/api';
|
||||
import { SpeciesTabs } from './species-tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IUCN_STATUS_COLORS, CITES_APPENDIX_COLORS } from '@/lib/utils';
|
||||
import { Loader2, ChevronLeft, Image, Globe, ExternalLink, FileText, Share2, Camera, Map, Calendar, FileSpreadsheet } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TradeDataTab } from './species/tabs/TradeDataTab';
|
||||
import { IucnTab } from './species/tabs/IucnTab';
|
||||
import { TimelineTab } from './species/tabs/TimelineTab';
|
||||
import { CitesTab } from './species/tabs/CitesTab';
|
||||
|
||||
// Add IUCN status full names mapping
|
||||
const IUCN_STATUS_FULL_NAMES: Record<string, string> = {
|
||||
'EX': 'Extinct',
|
||||
'EW': 'Extinct in the Wild',
|
||||
'CR': 'Critically Endangered',
|
||||
'EN': 'Endangered',
|
||||
'VU': 'Vulnerable',
|
||||
'NT': 'Near Threatened',
|
||||
'LC': 'Least Concern',
|
||||
'DD': 'Data Deficient',
|
||||
'NE': 'Not Evaluated'
|
||||
};
|
||||
|
||||
type ResultsContainerProps = {
|
||||
speciesId: string | null;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function ResultsContainer({ speciesId, onBack }: ResultsContainerProps) {
|
||||
const [isHeaderSticky, setIsHeaderSticky] = useState(false);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: species, isLoading, error } = useQuery({
|
||||
queryKey: ['species', speciesId],
|
||||
queryFn: () => getSpeciesById(speciesId as string),
|
||||
enabled: !!speciesId,
|
||||
});
|
||||
|
||||
const { data: imageData } = useQuery({
|
||||
queryKey: ['speciesImage', species?.scientific_name],
|
||||
queryFn: () => getSpeciesImages(species?.scientific_name || ''),
|
||||
enabled: !!species?.scientific_name,
|
||||
});
|
||||
|
||||
const { data: distributionData } = useQuery({
|
||||
queryKey: ['speciesDistribution', species?.id],
|
||||
queryFn: () => getSpeciesDistribution(species?.id || ''),
|
||||
enabled: !!species?.id,
|
||||
});
|
||||
|
||||
const { data: threatsData } = useQuery({
|
||||
queryKey: ['speciesThreats', species?.id],
|
||||
queryFn: () => getSpeciesThreats(species?.id || ''),
|
||||
enabled: !!species?.id,
|
||||
});
|
||||
|
||||
const { data: conservationMeasuresData } = useQuery({
|
||||
queryKey: ['speciesConservationMeasures', species?.id],
|
||||
queryFn: () => getConservationMeasures(species?.id || ''),
|
||||
enabled: !!species?.id,
|
||||
});
|
||||
|
||||
const { data: extendedInfoData } = useQuery({
|
||||
queryKey: ['speciesExtendedInfo', species?.id],
|
||||
queryFn: () => getSpeciesExtendedInfo(species?.id || ''),
|
||||
enabled: !!species?.id,
|
||||
});
|
||||
|
||||
const { data: timelineEventsData } = useQuery({
|
||||
queryKey: ['speciesTimelineEvents', species?.id],
|
||||
queryFn: () => getTimelineEvents(species?.id || ''),
|
||||
enabled: !!species?.id,
|
||||
});
|
||||
|
||||
// Handle header stickiness
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (headerRef.current) {
|
||||
setIsHeaderSticky(window.scrollY > headerRef.current.offsetHeight / 2);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
if (!speciesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 w-full items-center justify-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !species) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Error Loading Species</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">There was an error loading the species information. Please try again.</p>
|
||||
<Button onClick={onBack} variant="outline" size="sm">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Search
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'CR': return 'bg-red-600 text-white';
|
||||
case 'EN': return 'bg-orange-600 text-white';
|
||||
case 'VU': return 'bg-yellow-600 text-black';
|
||||
case 'NT': return 'bg-blue-600 text-white';
|
||||
case 'LC': return 'bg-green-600 text-white';
|
||||
default: return 'bg-gray-600 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
const getAppendixColor = (appendix: string) => {
|
||||
switch (appendix) {
|
||||
case 'I': return 'bg-red-600 text-white';
|
||||
case 'II': return 'bg-blue-600 text-white';
|
||||
case 'III': return 'bg-yellow-600 text-black';
|
||||
default: return 'bg-gray-600 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Hero Header Section - Background image */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="relative py-12"
|
||||
style={{
|
||||
backgroundImage: "url('/assets/images/heroarcit.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center"
|
||||
}}
|
||||
>
|
||||
{/* Dark overlay for better text readability */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50"></div>
|
||||
|
||||
<div className="container relative mx-auto px-4 z-10">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-white/10 text-white hover:bg-white/20 backdrop-blur-sm border-white/30"
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Species List
|
||||
</Button>
|
||||
|
||||
<div className="mt-8 text-center text-white">
|
||||
<h1 className="text-3xl md:text-5xl font-bold italic mb-3">{species.scientific_name}</h1>
|
||||
<p className="text-xl md:text-2xl text-blue-100">
|
||||
{species.common_name || species.primary_common_name}
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
{species.latest_assessment?.status && (
|
||||
<Badge className={`text-sm px-4 py-1.5 rounded-full ${getStatusColor(species.latest_assessment.status)}`}>
|
||||
IUCN: {species.latest_assessment.status}
|
||||
</Badge>
|
||||
)}
|
||||
{species.current_cites_listing && (
|
||||
<Badge className={`text-sm px-4 py-1.5 rounded-full ${getAppendixColor(species.current_cites_listing.appendix)}`}>
|
||||
CITES: Appendix {species.current_cites_listing.appendix}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Navigation Header */}
|
||||
<div
|
||||
className={`sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 py-2 ${
|
||||
isHeaderSticky ? 'shadow-md animate-in slide-in-from-top-2 duration-300' : 'opacity-0 pointer-events-none h-0 py-0 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
<div className="container mx-auto px-4 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mr-4"
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="font-medium italic text-lg">{species.scientific_name}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{species.common_name || species.primary_common_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{species.latest_assessment?.status && (
|
||||
<Badge
|
||||
className={`mt-1 ${species.latest_assessment?.status ? IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500' : 'bg-gray-500'}`}
|
||||
variant="default"
|
||||
>
|
||||
{species.latest_assessment?.status ? IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status : 'Not Assessed'}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="ml-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Tabs with negative margin to overlap with hero image */}
|
||||
<div className="mt-[-30px] mb-6 relative z-20">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 md:grid-cols-5 lg:grid-cols-6 rounded-lg bg-white dark:bg-gray-800 shadow-md">
|
||||
<TabsTrigger value="overview" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Overview</TabsTrigger>
|
||||
<TabsTrigger value="conservation" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Conservation</TabsTrigger>
|
||||
<TabsTrigger value="cites" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">
|
||||
CITES {species.cites_listings?.length > 0 && <span className="ml-1 text-xs bg-blue-600 text-white px-1 rounded-full">{species.cites_listings.length}</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="iucn" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">IUCN</TabsTrigger>
|
||||
<TabsTrigger value="trade" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Trade Data</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Timeline</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="my-4">
|
||||
<div className="text-xs text-gray-500 mb-4">
|
||||
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 <www.iucnredlist.org>,
|
||||
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Export PDF Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left column - Image and quick info */}
|
||||
<div className="md:col-span-1">
|
||||
{/* Species Image */}
|
||||
{imageData?.url ? (
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden mb-4">
|
||||
<img
|
||||
src={imageData.url}
|
||||
alt={species.scientific_name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<div className="absolute bottom-0 right-0 bg-black/60 px-2 py-1 text-[10px] text-white max-w-full truncate">
|
||||
{imageData.attribution || "© Photo Attribution"}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="absolute top-2 right-2 bg-white/80 h-8 w-8 p-0">
|
||||
<Camera className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-100 dark:bg-gray-800 aspect-square flex items-center justify-center rounded-lg mb-4">
|
||||
<div className="text-center p-6">
|
||||
<Image className="h-12 w-12 mx-auto text-gray-400 dark:text-gray-600" />
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">No image available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Taxonomy Card */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Taxonomy</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<dl className="grid grid-cols-2 gap-1 text-sm">
|
||||
<dt className="text-gray-500">Kingdom:</dt>
|
||||
<dd>{species.kingdom || "Not available"}</dd>
|
||||
<dt className="text-gray-500">Phylum:</dt>
|
||||
<dd>{species.phylum || "Not available"}</dd>
|
||||
<dt className="text-gray-500">Class:</dt>
|
||||
<dd>{species.class || "Not available"}</dd>
|
||||
<dt className="text-gray-500">Order:</dt>
|
||||
<dd>{species.order_name || "Not available"}</dd>
|
||||
<dt className="text-gray-500">Family:</dt>
|
||||
<dd>{species.family || "Not available"}</dd>
|
||||
<dt className="text-gray-500">Genus:</dt>
|
||||
<dd>{species.genus || "Not available"}</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Conservation Status Card */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Conservation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{species.latest_assessment && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">IUCN Red List:</h4>
|
||||
<Badge
|
||||
className={`mt-1 ${species.latest_assessment?.status ? IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500' : 'bg-gray-500'}`}
|
||||
variant="default"
|
||||
>
|
||||
{species.latest_assessment?.status ? IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status : 'Not Assessed'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">Assessment Year: {species.latest_assessment.year_published}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{species.current_cites_listing && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">CITES Listing:</h4>
|
||||
<Badge className={`mt-1 ${species.current_cites_listing?.appendix ? CITES_APPENDIX_COLORS[species.current_cites_listing.appendix] : 'bg-gray-500'}`}>
|
||||
Appendix {species.current_cites_listing?.appendix || 'N/A'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">Listed since: {new Date(species.current_cites_listing.listing_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* External Resources */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">External Resources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`https://www.iucnredlist.org/species/${species.sis_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
IUCN Red List
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
{species.current_cites_listing && (
|
||||
<a
|
||||
href={`https://checklist.cites.org/#/en/search/scientific_name=${encodeURIComponent(species.scientific_name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
CITES Checklist
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`https://www.gbif.org/species/search?q=${encodeURIComponent(species.scientific_name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<Map className="mr-2 h-4 w-4" />
|
||||
GBIF Species
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right column - Tab content */}
|
||||
<div className="md:col-span-2">
|
||||
{/* Tab Contents */}
|
||||
{/* Overview Tab (Combined Overview, Distribution, Threats) */}
|
||||
<TabsContent value="overview" className="mt-0 space-y-6">
|
||||
{/* Species Description Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Species Overview</CardTitle>
|
||||
<CardDescription>Basic information and description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p>
|
||||
The {species.common_name || species.scientific_name} ({species.scientific_name}) is a species of
|
||||
{species.class ? ` ${species.class.toLowerCase()}` : ' animal'} in the family
|
||||
{species.family ? ` ${species.family}` : ''}.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This species is currently assessed as <strong>{species.latest_assessment?.status || 'Least Concern (LC)'}</strong> on
|
||||
the IUCN Red List and is listed in <strong>CITES Appendix {species.current_cites_listing?.appendix || 'I'}</strong>.
|
||||
</p>
|
||||
|
||||
{/* Extended description from the species data */}
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xl font-medium mb-2">Description</h3>
|
||||
{extendedInfoData?.description ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.description}</p>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Extended species description will be added soon. The Arctic Species Tracker is being updated to include comprehensive species descriptions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xl font-medium mb-2">Habitat</h3>
|
||||
{extendedInfoData?.habitat_description ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.habitat_description}</p>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Habitat information will be added soon, detailing the natural environment where this species lives.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xl font-medium mb-2">Population</h3>
|
||||
{extendedInfoData?.population_size ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.population_size}</p>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Population information including trends and estimates will be added soon.
|
||||
</p>
|
||||
)}
|
||||
{extendedInfoData?.population_trend && (
|
||||
<p className="text-gray-700 dark:text-gray-300 mt-2">
|
||||
<strong>Population Trend:</strong> {extendedInfoData.population_trend}
|
||||
</p>
|
||||
)}
|
||||
{extendedInfoData?.generation_length && (
|
||||
<p className="text-gray-700 dark:text-gray-300 mt-2">
|
||||
<strong>Generation Length:</strong> {extendedInfoData.generation_length} years
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extendedInfoData?.movement_patterns && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xl font-medium mb-2">Movement Patterns</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.movement_patterns}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Distribution Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Map className="mr-2 h-5 w-5" />
|
||||
Range & Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>Geographic range and distribution patterns</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 border border-dashed border-gray-300 dark:border-gray-700 rounded-md h-56 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Map className="h-8 w-8 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">Interactive distribution map coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse table-auto">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800 text-left">
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Region</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Presence</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Origin</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Seasonality</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!distributionData || distributionData.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700 text-center" colSpan={5}>
|
||||
No distribution data available for this species.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
distributionData.map((range) => (
|
||||
<tr key={range.id} className="border-b">
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.region}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.presence_code}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.origin_code}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.seasonal_code || '-'}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.notes || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Threats Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Threats</CardTitle>
|
||||
<CardDescription>Known threats affecting this species</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
{extendedInfoData?.threats_overview ? (
|
||||
<>
|
||||
<h3>Overview</h3>
|
||||
<p>{extendedInfoData.threats_overview}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>This section will display information about threats to this species, including human activities, climate change impacts, habitat loss, and other factors affecting its survival.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse table-auto">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800 text-left">
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Threat Type</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Severity</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Scope</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Timing</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!threatsData || threatsData.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700 text-center" colSpan={5}>
|
||||
No threat data available for this species.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
threatsData.map((threat) => (
|
||||
<tr key={threat.id} className="border-b">
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.threat_type}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.severity || '-'}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.scope || '-'}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.timing || '-'}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.description || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Conservation Tab */}
|
||||
<TabsContent value="conservation" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Conservation Measures</CardTitle>
|
||||
<CardDescription>Actions taken to protect this species</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
{extendedInfoData?.conservation_overview ? (
|
||||
<>
|
||||
<h3>Overview</h3>
|
||||
<p>{extendedInfoData.conservation_overview}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>This section will display information about conservation measures in place for this species, including protected areas, breeding programs, legal protections, and other efforts to ensure its survival.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse table-auto">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800 text-left">
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Measure Type</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Status</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Effectiveness</th>
|
||||
<th className="p-2 border border-gray-200 dark:border-gray-700">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!conservationMeasuresData || conservationMeasuresData.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700 text-center" colSpan={4}>
|
||||
No conservation measure data available for this species.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
conservationMeasuresData.map((measure) => (
|
||||
<tr key={measure.id} className="border-b">
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.measure_type}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.status || '-'}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.effectiveness || '-'}</td>
|
||||
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.description || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Use and Trade Information */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium mb-2">Use and Trade</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 border border-dashed p-4 rounded-md">
|
||||
{extendedInfoData?.use_and_trade ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.use_and_trade}</p>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Information about how this species is used and traded will be displayed here. This will include details about commercial exploitation, traditional uses, and regulation of trade.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* CITES Tab */}
|
||||
<TabsContent value="cites" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CITES Listings</CardTitle>
|
||||
<CardDescription>Convention on International Trade in Endangered Species listings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<CitesTab species={species} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* IUCN Tab */}
|
||||
<TabsContent value="iucn" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IUCN Assessment</CardTitle>
|
||||
<CardDescription>International Union for Conservation of Nature assessments</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<IucnTab species={species} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Trade Data Tab */}
|
||||
<TabsContent value="trade" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CITES Trade Data</CardTitle>
|
||||
<CardDescription>International trade records reported to CITES</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TradeDataTab species={species} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Timeline Tab */}
|
||||
<TabsContent value="timeline" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Conservation Timeline</CardTitle>
|
||||
<CardDescription>Historical conservation and trade events</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TimelineTab species={species} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,94 +2,143 @@ import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAllSpecies } from '@/lib/api';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getAllSpecies, type Species, CommonName } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IUCN_STATUS_COLORS } from '@/lib/utils';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string) => void }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedTerm, setDebouncedTerm] = useState('');
|
||||
// Filter categories
|
||||
const FILTER_CATEGORIES = {
|
||||
IUCN: ['CR', 'EN', 'VU', 'NT', 'LC', 'DD', 'NE'],
|
||||
CITES: ['I', 'II', 'III']
|
||||
} as const;
|
||||
|
||||
// Debounce search term
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
if (value.length >= 3) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setDebouncedTerm(value);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else if (value.length === 0) {
|
||||
setDebouncedTerm('');
|
||||
interface SearchFormProps {
|
||||
onSpeciesSelect: (species: Species) => void;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch search results
|
||||
const { data: searchResults, isLoading } = useQuery({
|
||||
queryKey: ['searchSpecies', debouncedTerm],
|
||||
queryFn: () => getAllSpecies(),
|
||||
enabled: debouncedTerm.length >= 3,
|
||||
export function SearchForm({ onSpeciesSelect }: SearchFormProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFilters, setSelectedFilters] = useState<{
|
||||
IUCN: string[];
|
||||
CITES: string[];
|
||||
}>({
|
||||
IUCN: [],
|
||||
CITES: []
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchTerm.length >= 3) {
|
||||
setDebouncedTerm(searchTerm);
|
||||
}
|
||||
const { data: species, isLoading } = useQuery({
|
||||
queryKey: ['species'],
|
||||
queryFn: getAllSpecies
|
||||
});
|
||||
|
||||
const toggleFilter = (category: keyof typeof FILTER_CATEGORIES, value: string) => {
|
||||
setSelectedFilters(prev => ({
|
||||
...prev,
|
||||
[category]: prev[category].includes(value)
|
||||
? prev[category].filter(v => v !== value)
|
||||
: [...prev[category], value]
|
||||
}));
|
||||
};
|
||||
|
||||
// Filter results based on search term
|
||||
const filteredResults = searchResults?.filter(species =>
|
||||
species.scientific_name.toLowerCase().includes(debouncedTerm.toLowerCase()) ||
|
||||
species.common_name?.toLowerCase().includes(debouncedTerm.toLowerCase())
|
||||
);
|
||||
const filteredSpecies = species?.filter(s => {
|
||||
const matchesSearch = s.scientific_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.common_names?.some((commonName: CommonName) => commonName.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesIUCN = selectedFilters.IUCN.length === 0 ||
|
||||
selectedFilters.IUCN.includes(s.latest_assessment?.status || 'NE');
|
||||
|
||||
const matchesCITES = selectedFilters.CITES.length === 0 ||
|
||||
selectedFilters.CITES.includes(s.current_cites_listing?.appendix || 'NL');
|
||||
|
||||
return matchesSearch && matchesIUCN && matchesCITES;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search Species</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search for a species (scientific or common name)..."
|
||||
id="search"
|
||||
placeholder="Search by scientific or common name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pr-10"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader2 className="absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Search className="absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={searchTerm.length < 3}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Search Results */}
|
||||
{filteredResults && filteredResults.length > 0 && (
|
||||
<Card className="mt-2">
|
||||
<CardContent className="p-2">
|
||||
<ul className="divide-y divide-border">
|
||||
{filteredResults.map((species) => (
|
||||
<li
|
||||
key={species.id}
|
||||
className="cursor-pointer p-2 hover:bg-muted"
|
||||
onClick={() => onSelectSpecies(species.id)}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IUCN Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{FILTER_CATEGORIES.IUCN.map(status => (
|
||||
<Badge
|
||||
key={status}
|
||||
variant="outline"
|
||||
className={`cursor-pointer ${selectedFilters.IUCN.includes(status) ? IUCN_STATUS_COLORS[status as keyof typeof IUCN_STATUS_COLORS] : ''}`}
|
||||
onClick={() => toggleFilter('IUCN', status)}
|
||||
>
|
||||
<div className="font-medium italic">{species.scientific_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{species.common_name}</div>
|
||||
</li>
|
||||
{status}
|
||||
</Badge>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filteredResults && filteredResults.length === 0 && debouncedTerm.length >= 3 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">No species found matching your search.</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CITES Appendix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{FILTER_CATEGORIES.CITES.map(appendix => (
|
||||
<Badge
|
||||
key={appendix}
|
||||
variant="outline"
|
||||
className={`cursor-pointer ${selectedFilters.CITES.includes(appendix) ? 'bg-primary text-primary-foreground' : ''}`}
|
||||
onClick={() => toggleFilter('CITES', appendix)}
|
||||
>
|
||||
{appendix}
|
||||
</Badge>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[400px] rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center">Loading species...</div>
|
||||
) : filteredSpecies?.length === 0 ? (
|
||||
<div className="p-4 text-center">No species found</div>
|
||||
) : (
|
||||
<div className="space-y-2 p-4">
|
||||
{filteredSpecies?.map(species => (
|
||||
<Button
|
||||
key={species.id}
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
to={`/species/${species.id}`}
|
||||
onClick={() => onSpeciesSelect(species)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{species.scientific_name}</span>
|
||||
{species.common_names && species.common_names.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{species.common_names[0]?.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,10 +3,25 @@ import { Button } from '@/components/ui/button';
|
||||
import { FileText, Loader2 } from 'lucide-react';
|
||||
import { SpeciesDetails } from '@/lib/api';
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCitesTradeRecords } from '@/lib/api';
|
||||
|
||||
// Simple date formatter (Add directly into this file)
|
||||
function formatDate(dateString: string | null | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
// Assuming dateString is in YYYY-MM-DD format
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", dateString, e);
|
||||
return dateString; // Return original string on error
|
||||
}
|
||||
}
|
||||
|
||||
interface SpeciesReportProps {
|
||||
species: SpeciesDetails;
|
||||
imageUrl?: string | null;
|
||||
@ -33,7 +48,7 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
|
||||
let currentY = margin;
|
||||
|
||||
// Helper function to check if we need a new page
|
||||
const checkPageBreak = (neededSpace: number) => {
|
||||
const checkPageBreak = (neededSpace: number): boolean => {
|
||||
if (currentY + neededSpace > pageHeight - margin) {
|
||||
doc.addPage();
|
||||
currentY = margin;
|
||||
@ -43,7 +58,7 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
|
||||
};
|
||||
|
||||
// Helper function for wrapped text
|
||||
const addWrappedText = (text: string, x: number, fontSize: number, maxWidth: number) => {
|
||||
const addWrappedText = (text: string, x: number, fontSize: number, maxWidth: number): void => {
|
||||
doc.setFontSize(fontSize);
|
||||
const lines = doc.splitTextToSize(text, maxWidth);
|
||||
lines.forEach((line: string) => {
|
||||
@ -69,10 +84,10 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
// Add common name if available
|
||||
if (species.common_name) {
|
||||
if (species.common_names?.[0]?.name) {
|
||||
checkPageBreak(10);
|
||||
doc.setFontSize(12);
|
||||
const wrappedCommonName = doc.splitTextToSize(species.common_name, pageWidth - 2 * margin);
|
||||
const wrappedCommonName = doc.splitTextToSize(species.common_names[0].name, pageWidth - 2 * margin);
|
||||
wrappedCommonName.forEach((line: string) => {
|
||||
doc.text(line, pageWidth / 2, currentY, { align: 'center' });
|
||||
currentY += 5;
|
||||
@ -85,8 +100,8 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = reject;
|
||||
img.src = imageUrl;
|
||||
});
|
||||
@ -129,7 +144,7 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
|
||||
['Family', species.family],
|
||||
['Genus', species.genus],
|
||||
['Species', species.species_name],
|
||||
];
|
||||
] as const;
|
||||
|
||||
taxonomicInfo.forEach(([label, value]) => {
|
||||
if (value) {
|
||||
|
||||
@ -1,82 +1,343 @@
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { SpeciesDetails } from "@/lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getSpeciesImages } from "@/lib/api";
|
||||
import { getSpeciesImages, getSpeciesDistribution, getConservationMeasures, getSpeciesExtendedInfo } from "@/lib/api";
|
||||
import { SpeciesReport } from './species-report';
|
||||
import { OverviewTab } from "./species/tabs/OverviewTab";
|
||||
import { CitesTab } from "./species/tabs/CitesTab";
|
||||
import { IucnTab } from "./species/tabs/IucnTab";
|
||||
import { TradeDataTab } from "./species/tabs/TradeDataTab";
|
||||
import { TimelineTab } from "./species/tabs/TimelineTab";
|
||||
import { SpeciesImage } from "./species/SpeciesImage";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CitesListingsTab } from './species/tabs/CitesListingsTab';
|
||||
|
||||
type SpeciesTabsProps = {
|
||||
interface ExternalLinkIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ExternalLinkIcon: React.FC<ExternalLinkIconProps> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SpeciesTabsProps {
|
||||
species: SpeciesDetails;
|
||||
};
|
||||
}
|
||||
|
||||
export function SpeciesTabs({ species }: SpeciesTabsProps) {
|
||||
// Add image fetching query
|
||||
const { data: imageData } = useQuery({
|
||||
queryKey: ["speciesImage", species.scientific_name],
|
||||
queryFn: () => getSpeciesImages(species.scientific_name),
|
||||
enabled: !!species.scientific_name,
|
||||
});
|
||||
|
||||
// Determine default tab based on available data
|
||||
const defaultTab = "overview"; // Always start with Overview tab
|
||||
const { data: distributionData } = useQuery({
|
||||
queryKey: ["speciesDistribution", species.id],
|
||||
queryFn: () => getSpeciesDistribution(species.id),
|
||||
enabled: !!species.id,
|
||||
});
|
||||
|
||||
const { data: conservationData } = useQuery({
|
||||
queryKey: ["conservationMeasures", species.id],
|
||||
queryFn: () => getConservationMeasures(species.id),
|
||||
enabled: !!species.id,
|
||||
});
|
||||
|
||||
const { data: extendedInfo } = useQuery({
|
||||
queryKey: ["speciesExtendedInfo", species.id],
|
||||
queryFn: () => getSpeciesExtendedInfo(species.id),
|
||||
enabled: !!species.id,
|
||||
});
|
||||
|
||||
const defaultTab = "overview";
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 <www.iucnredlist.org>,
|
||||
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 www.iucnredlist.org
|
||||
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<div className="w-48 flex-shrink-0">
|
||||
<SpeciesReport species={species} imageUrl={imageData?.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="cites" className={species.cites_listings && species.cites_listings.length > 0 ? "font-semibold" : ""}>
|
||||
CITES
|
||||
{species.cites_listings && species.cites_listings.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-primary text-[10px] text-primary-foreground px-1.5">{species.cites_listings.length}</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6">
|
||||
<div className="md:col-span-1">
|
||||
<SpeciesImage scientificName={species.scientific_name} imageData={imageData} />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<h2 className="text-2xl font-bold italic">{species.scientific_name}</h2>
|
||||
<p className="text-xl text-muted-foreground">{species.common_names?.[0]?.name || 'No common name available'}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{species.latest_assessment && (
|
||||
<div className="border rounded-md p-2 text-sm">
|
||||
<div className="text-xs text-muted-foreground mb-1">IUCN Red List Status</div>
|
||||
<div className="font-medium">{species.latest_assessment.status || "Not Assessed"}</div>
|
||||
<div>Year: {species.latest_assessment.year_published}</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="iucn">IUCN</TabsTrigger>
|
||||
<TabsTrigger value="trade">Trade Data</TabsTrigger>
|
||||
<TabsTrigger value="timeline">Timeline</TabsTrigger>
|
||||
{species.current_cites_listing && (
|
||||
<div className="border rounded-md p-2 text-sm">
|
||||
<div className="text-xs text-muted-foreground mb-1">CITES Listing</div>
|
||||
<div className="font-medium">Appendix {species.current_cites_listing.appendix}</div>
|
||||
<div>Since: {new Date(species.current_cites_listing.listing_date).toLocaleDateString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<h3 className="text-lg font-semibold mb-1">General Information</h3>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{extendedInfo?.description
|
||||
? extendedInfo.description.substring(0, 250) + (extendedInfo.description.length > 250 ? '...' : '')
|
||||
: "General information about the species, including habitat, distribution range, physical characteristics, and other relevant details will appear here."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<h3 className="text-lg font-semibold mb-1">External Resources</h3>
|
||||
<ul className="list-none space-y-1 text-base">
|
||||
<li><a href="#" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center"><ExternalLinkIcon className="mr-2 h-4 w-4 flex-shrink-0" />IUCN Red List</a></li>
|
||||
<li><a href="#" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center"><ExternalLinkIcon className="mr-2 h-4 w-4 flex-shrink-0" />CITES Checklist</a></li>
|
||||
<li><a href="#" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center"><ExternalLinkIcon className="mr-2 h-4 w-4 flex-shrink-0" />GBIF Species</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<h3 className="text-lg font-semibold mb-1">Taxonomy</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-base">
|
||||
<div className="font-medium">Class:</div> <div>{species.class}</div>
|
||||
<div className="font-medium">Family:</div> <div>{species.family}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={defaultTab} className="w-full mt-8 species-detail-tabs">
|
||||
<TabsList className="w-full mb-4 grid grid-cols-3 sm:grid-cols-5">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="cites">CITES</TabsTrigger>
|
||||
<TabsTrigger value="cms">CMS</TabsTrigger>
|
||||
<TabsTrigger value="conservation">Conservation</TabsTrigger>
|
||||
<TabsTrigger value="trade">Trade</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab species={species} imageData={imageData} />
|
||||
<TabsContent value="overview" className="pt-6">
|
||||
<div className="space-y-6 text-base">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Species Description</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{extendedInfo?.description || "No detailed description available for this species."}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Habitat</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{extendedInfo?.habitat_description || "No habitat information available for this species."}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Threats Overview</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{extendedInfo?.threats_overview || "No threats overview available."}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Population & Distribution</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Population Trend</h4>
|
||||
<p className="text-muted-foreground">{extendedInfo?.population_trend || "No population trend data available."}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Population Size</h4>
|
||||
<p className="text-muted-foreground">{extendedInfo?.population_size || "No population size data available."}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Distribution Range</h4>
|
||||
{distributionData && distributionData.length > 0 ? (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1 px-2 font-medium">Region</th>
|
||||
<th className="text-left py-1 px-2 font-medium">Presence</th>
|
||||
<th className="text-left py-1 px-2 font-medium">Origin</th>
|
||||
<th className="text-left py-1 px-2 font-medium">Seasonality</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{distributionData.map((range) => (
|
||||
<tr key={range.id} className="border-b">
|
||||
<td className="py-1 px-2">{range.region}</td>
|
||||
<td className="py-1 px-2">{range.presence_code}</td>
|
||||
<td className="py-1 px-2">{range.origin_code}</td>
|
||||
<td className="py-1 px-2">{range.seasonal_code || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No distribution data available.</p>
|
||||
)}
|
||||
<div className="bg-muted rounded-md p-4 text-sm mt-4">
|
||||
<p className="text-muted-foreground text-center">Distribution map visualization coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>IUCN Assessment</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<IucnTab species={species} extendedInfo={extendedInfo} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{extendedInfo?.movement_patterns && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Movement Patterns</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{extendedInfo.movement_patterns}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* CITES Tab */}
|
||||
<TabsContent value="cites">
|
||||
<CitesTab species={species} />
|
||||
<TabsContent value="cites" className="pt-6">
|
||||
<div className="space-y-6 text-base">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>CITES Listings</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<CitesListingsTab species={species} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>CITES Publications</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Relevant CITES publications will be listed here.</p>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<li>Breaking the Ice: International trade in Narwhals... (2015)</li>
|
||||
<li>Shifting priorities for Narwhal conservation... (2020)</li>
|
||||
<li>(More publications links/info needed)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* IUCN Tab */}
|
||||
<TabsContent value="iucn">
|
||||
<IucnTab species={species} />
|
||||
<TabsContent value="cms" className="pt-6">
|
||||
<div className="space-y-6 text-base">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>CMS Agreements & MOUs</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Information on relevant CMS Agreements and Memoranda of Understanding will be displayed here. (Requires data source: Species+?)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>CMS Publications</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Relevant CMS publications will be listed here. (Requires data source)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Trade Data Tab */}
|
||||
<TabsContent value="trade">
|
||||
<TabsContent value="conservation" className="pt-6">
|
||||
<div className="space-y-6 text-base">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Conservation Measures</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{extendedInfo?.conservation_overview && (
|
||||
<p className="text-muted-foreground mb-4">{extendedInfo.conservation_overview}</p>
|
||||
)}
|
||||
{conservationData && conservationData.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1 px-2 font-medium">Measure</th>
|
||||
<th className="text-left py-1 px-2 font-medium">Status</th>
|
||||
<th className="text-left py-1 px-2 font-medium">Effectiveness</th>
|
||||
<th className="text-left py-1 px-2 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conservationData.map((measure) => (
|
||||
<tr key={measure.id} className="border-b">
|
||||
<td className="py-1 px-2">{measure.measure_type}</td>
|
||||
<td className="py-1 px-2">{measure.status || '-'}</td>
|
||||
<td className="py-1 px-2">{measure.effectiveness || '-'}</td>
|
||||
<td className="py-1 px-2">{measure.description || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No specific conservation measures data available.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{extendedInfo?.use_and_trade && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Use and Trade</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{extendedInfo.use_and_trade}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Conservation Frameworks</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Conservation frameworks which address this species (e.g., CBMP, NAMMCO) will be detailed here. (Requires data source)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Relevant Reports</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Relevant reports (e.g., NAMMCO Publications, CBMP Assessments) will be listed here. (Requires data source)</p>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<li>NAMMCO Publications</li>
|
||||
<li>CBMP Assessments</li>
|
||||
<li>CBMP Marine Monitoring Plan</li>
|
||||
<li>(More report links/info needed)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trade" className="pt-6">
|
||||
<TradeDataTab species={species} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Timeline Tab */}
|
||||
<TabsContent value="timeline">
|
||||
<TimelineTab species={species} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="w-full text-center mt-8 py-4 text-sm text-muted-foreground border-t">
|
||||
|
||||
<div className="w-full text-center mt-8 py-4 text-base text-muted-foreground border-t">
|
||||
© Magnus Smari Smarason
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,26 +1,29 @@
|
||||
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";
|
||||
// Import Dialog components and Expand icon
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog";
|
||||
import { ImageIcon, X, Expand } from "lucide-react";
|
||||
|
||||
type SpeciesImageProps = {
|
||||
interface SpeciesImageProps {
|
||||
scientificName: string;
|
||||
imageData?: {
|
||||
url: string;
|
||||
attribution: string;
|
||||
license?: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function SpeciesImage({ scientificName, imageData }: SpeciesImageProps) {
|
||||
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
// Wrap everything in the Dialog component
|
||||
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
|
||||
{/* Main container div - Relative positioning needed for the absolute button */}
|
||||
<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)}
|
||||
className="relative aspect-[16/9] w-full h-60 overflow-hidden rounded-lg bg-muted mb-6"
|
||||
>
|
||||
{/* Image rendering logic */}
|
||||
{imageData ? (
|
||||
<>
|
||||
<img
|
||||
@ -28,14 +31,13 @@ export function SpeciesImage({ scientificName, imageData }: SpeciesImageProps) {
|
||||
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">
|
||||
{/* Attribution overlay */}
|
||||
<div className="absolute bottom-0 right-0 bg-black/50 p-2 text-sm text-white pointer-events-none"> {/* Added pointer-events-none back */}
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
// Placeholder content if no image data
|
||||
<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>
|
||||
@ -43,38 +45,45 @@ export function SpeciesImage({ scientificName, imageData }: SpeciesImageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog for full-size image */}
|
||||
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
|
||||
{/* DialogTrigger moved AFTER image div, NOT using asChild */}
|
||||
{/* Renders its own button, containing the icon */}
|
||||
<DialogTrigger
|
||||
disabled={!imageData}
|
||||
className="mt-2 inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-3" // Basic button styling
|
||||
>
|
||||
<Expand className="h-4 w-4 mr-2" /> Show Full Picture
|
||||
</DialogTrigger>
|
||||
|
||||
{/* Dialog content */}
|
||||
<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" />
|
||||
{/* Use DialogClose for the close button */}
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{imageData && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageData.url.replace('medium', 'original')}
|
||||
// Attempt to load original, fallback to medium if replace fails (e.g., URL doesn't contain 'medium')
|
||||
src={imageData.url.includes('medium') ? imageData.url.replace('medium', 'original') : imageData.url}
|
||||
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>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 p-4 text-white rounded-b-lg pointer-events-none"> {/* Added pointer-events-none back */}
|
||||
<p className="text-base">{imageData.attribution}</p>
|
||||
{imageData.license && (
|
||||
<p className="text-xs mt-1">License: {imageData.license}</p>
|
||||
<p className="text-sm mt-1">License: {imageData.license}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/components/species/tabs/CitesListingsTab.tsx
Normal file
71
src/components/species/tabs/CitesListingsTab.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { SpeciesDetails, CitesListing } from "@/lib/api";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface CitesListingsTabProps {
|
||||
species: SpeciesDetails;
|
||||
}
|
||||
|
||||
// Helper to sort listings by date, newest first
|
||||
const sortListingsDesc = (a: CitesListing, b: CitesListing) =>
|
||||
new Date(b.listing_date).getTime() - new Date(a.listing_date).getTime();
|
||||
|
||||
// Simple date formatter (Add directly into this file)
|
||||
function formatDate(dateString: string | null | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
// Assuming dateString is in YYYY-MM-DD format
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", dateString, e);
|
||||
return dateString; // Return original string on error
|
||||
}
|
||||
}
|
||||
|
||||
export function CitesListingsTab({ species }: CitesListingsTabProps) {
|
||||
const sortedListings = species.cites_listings?.sort(sortListingsDesc) || [];
|
||||
|
||||
if (!sortedListings || sortedListings.length === 0) {
|
||||
return <p className="text-muted-foreground">No CITES listing information available for this species.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Appendix</TableHead>
|
||||
<TableHead>Effective Date</TableHead>
|
||||
<TableHead>Status</TableHead> {/* Renamed from 'Current' */}
|
||||
{/* Add other relevant columns if available and needed, e.g., Notes */}
|
||||
{/* <TableHead>Notes</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedListings.map((listing, index) => (
|
||||
<TableRow key={listing.id}>
|
||||
<TableCell>
|
||||
<Badge variant={listing.appendix === 'I' ? 'destructive' : listing.appendix === 'II' ? 'default' : 'secondary'} className="whitespace-nowrap">
|
||||
Appendix {listing.appendix}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(listing.listing_date)}</TableCell>
|
||||
<TableCell>
|
||||
{index === 0 ? (
|
||||
<Badge variant="default">Active</Badge> // Changed variant from 'success' to 'default'
|
||||
) : (
|
||||
<Badge variant="outline">Historical</Badge> // Older listings marked as Historical
|
||||
)}
|
||||
</TableCell>
|
||||
{/* <TableCell>{listing.notes || '-'}</TableCell> */}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,257 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,120 +1,88 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SpeciesDetails } from "@/lib/api";
|
||||
import { Card, CardContent, 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";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { IUCN_STATUS_COLORS, IUCN_STATUS_PROGRESS } from "@/lib/utils";
|
||||
|
||||
type IucnTabProps = {
|
||||
interface IucnTabProps {
|
||||
species: SpeciesDetails;
|
||||
extendedInfo?: {
|
||||
description: string | null;
|
||||
habitat_description: string | null;
|
||||
population_trend: string | null;
|
||||
population_size: string | null;
|
||||
generation_length: number | null;
|
||||
movement_patterns: string | null;
|
||||
use_and_trade: string | null;
|
||||
threats_overview: string | null;
|
||||
conservation_overview: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function IucnTab({ species, extendedInfo }: IucnTabProps) {
|
||||
const status = species.latest_assessment?.status || 'NE';
|
||||
const progressValue = IUCN_STATUS_PROGRESS[status as keyof typeof IUCN_STATUS_PROGRESS];
|
||||
const statusColor = IUCN_STATUS_COLORS[status as keyof typeof IUCN_STATUS_COLORS];
|
||||
|
||||
export function IucnTab({ species }: IucnTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IUCN Red List Assessments</CardTitle>
|
||||
<CardDescription>International Union for Conservation of Nature assessments</CardDescription>
|
||||
<CardTitle>IUCN Red List Status</CardTitle>
|
||||
</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}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className={`text-sm px-4 py-2 rounded-full ${statusColor}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Last assessed: {species.latest_assessment?.year_published || 'Not assessed'}
|
||||
</span>
|
||||
</div>
|
||||
{assessment.scope_description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{assessment.scope_description}
|
||||
<Progress value={progressValue} className="h-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{species.latest_assessment?.scope_description || 'This species has not been assessed for the IUCN Red List'}
|
||||
</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>
|
||||
|
||||
{/* Population Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Population Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{extendedInfo?.population_trend || 'Population trend data not available'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habitat and Ecology */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Habitat and Ecology</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{extendedInfo?.habitat_description || 'Habitat and ecology information not available'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Threats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Threats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{extendedInfo?.threats_overview || 'Threat information not available'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,19 +1,27 @@
|
||||
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";
|
||||
// Removed unused: import { Badge } from "@/components/ui/badge";
|
||||
// import { SpeciesDetails } from "@/lib/api"; // Commented out to satisfy eslint
|
||||
// Removed unused: import { formatDate, IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES, CITES_APPENDIX_COLORS } from "@/lib/utils";
|
||||
// Removed unused: import { SpeciesImage } from "../SpeciesImage";
|
||||
|
||||
type OverviewTabProps = {
|
||||
species: SpeciesDetails;
|
||||
imageData?: {
|
||||
url: string;
|
||||
attribution: string;
|
||||
license?: string;
|
||||
} | null;
|
||||
};
|
||||
// type OverviewTabProps = { // Commented out to satisfy eslint
|
||||
// species: SpeciesDetails; // Although unused, keep for type consistency if needed elsewhere
|
||||
// imageData?: {
|
||||
// url: string;
|
||||
// attribution: string;
|
||||
// license?: string;
|
||||
// } | null;
|
||||
// };
|
||||
|
||||
export function OverviewTab({ species, imageData }: OverviewTabProps) {
|
||||
/**
|
||||
* LEGACY COMPONENT - This version is no longer used
|
||||
* The content has been moved to the main species-tabs.tsx component
|
||||
* and split across multiple tabs for better organization.
|
||||
*
|
||||
* This component is kept for reference.
|
||||
*/
|
||||
export function OverviewTab() {
|
||||
// Parameters are unused as this is a legacy component
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
@ -23,34 +31,9 @@ export function OverviewTab({ species, imageData }: OverviewTabProps) {
|
||||
</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>
|
||||
<div className="text-center p-4 bg-muted rounded-md">
|
||||
<p>This content has been moved to the main view above the tabs.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -60,53 +43,9 @@ export function OverviewTab({ species, imageData }: OverviewTabProps) {
|
||||
<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 className="text-center p-4 bg-muted rounded-md">
|
||||
<p>This content has been moved to other tabs for better organization.</p>
|
||||
<p className="text-base text-muted-foreground mt-2">Please see the Distribution, Threats, and Conservation tabs.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,28 +1,42 @@
|
||||
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 { Filter, Loader2 } from "lucide-react";
|
||||
import { SpeciesDetails, CitesTradeRecord } from "@/lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCitesTradeRecords } from "@/lib/api";
|
||||
import { useTradeDataVisualization } from "@/hooks/useTradeDataVisualization";
|
||||
import { useTradeRecordFilters } from "@/hooks/useTradeRecordFilters";
|
||||
import { TradeCharts } from "../visualizations/TradeCharts";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Filter, Loader2 } from 'lucide-react';
|
||||
import { SpeciesDetails, CitesTradeRecord } from '@/lib/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCitesTradeRecords } from '@/lib/api';
|
||||
import { useTradeDataVisualization } from '@/hooks/useTradeDataVisualization';
|
||||
import { useTradeRecordFilters, CountryOption } from '@/hooks/useTradeRecordFilters';
|
||||
import { getCountryName } from '@/lib/countries';
|
||||
import { TradeCharts } from '../visualizations/TradeCharts';
|
||||
import { getTimelineEvents, TimelineEvent } from '@/lib/api';
|
||||
import { getCatchRecords, CatchRecord } from '@/lib/api';
|
||||
import { CatchChart } from '../visualizations/CatchChart';
|
||||
|
||||
type TradeDataTabProps = {
|
||||
interface TradeDataTabProps {
|
||||
species: SpeciesDetails;
|
||||
};
|
||||
}
|
||||
|
||||
export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
// Fetch trade records
|
||||
const isNarwhal = species.scientific_name === 'Monodon monoceros';
|
||||
|
||||
const { data: tradeRecords, isLoading: tradeLoading, error: tradeError } = useQuery({
|
||||
queryKey: ["tradeRecords", species.id],
|
||||
queryFn: () => getCitesTradeRecords(species.id),
|
||||
});
|
||||
|
||||
// Use hooks for filtering
|
||||
const { data: timelineEvents } = useQuery<TimelineEvent[], Error>({
|
||||
queryKey: ["timelineEvents", species.id],
|
||||
queryFn: () => getTimelineEvents(species.id),
|
||||
});
|
||||
|
||||
const { data: catchRecords, isLoading: catchLoading, error: catchError } = useQuery<CatchRecord[], Error>({
|
||||
queryKey: ["catchRecords", species.id],
|
||||
queryFn: () => getCatchRecords(species.id),
|
||||
enabled: isNarwhal,
|
||||
});
|
||||
|
||||
const {
|
||||
startYearFilter,
|
||||
setStartYearFilter,
|
||||
@ -30,12 +44,17 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
setEndYearFilter,
|
||||
termFilter,
|
||||
setTermFilter,
|
||||
importerFilter,
|
||||
setImporterFilter,
|
||||
exporterFilter,
|
||||
setExporterFilter,
|
||||
filteredRecords,
|
||||
resetFilters,
|
||||
yearRange
|
||||
yearRange,
|
||||
uniqueImporters,
|
||||
uniqueExporters
|
||||
} = useTradeRecordFilters(tradeRecords);
|
||||
|
||||
// Use hook for visualization with filtered records
|
||||
const {
|
||||
visualizationData,
|
||||
years,
|
||||
@ -58,17 +77,16 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
) : tradeError ? (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-red-800">Error loading trade records. Please try again later.</p>
|
||||
<p className="mt-2 text-xs text-red-700">{tradeError.message || 'Unknown error'}</p>
|
||||
<p className="mt-2 text-sm text-red-700">{(tradeError as Error)?.message || 'Unknown error'}</p>
|
||||
</div>
|
||||
) : tradeRecords && tradeRecords.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{/* Trade Filters */}
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="mb-2 flex items-center">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">Filter Trade Records</h3>
|
||||
<Filter className="mr-2 h-5 w-5" />
|
||||
<h3 className="text-base font-medium">Filter Trade Records</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-year-filter">Start Year</Label>
|
||||
<Select
|
||||
@ -126,10 +144,51 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="importer-filter">Importer</Label>
|
||||
<Select
|
||||
value={importerFilter}
|
||||
onValueChange={setImporterFilter}
|
||||
>
|
||||
<SelectTrigger id="importer-filter">
|
||||
<SelectValue placeholder="All Importers">
|
||||
{importerFilter === 'all' ? 'All Importers' : getCountryName(importerFilter)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Importers</SelectItem>
|
||||
{uniqueImporters.map((country: CountryOption) => (
|
||||
<SelectItem key={`importer-${country.code}`} value={country.code}>
|
||||
{country.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="exporter-filter">Exporter</Label>
|
||||
<Select
|
||||
value={exporterFilter}
|
||||
onValueChange={setExporterFilter}
|
||||
>
|
||||
<SelectTrigger id="exporter-filter">
|
||||
<SelectValue placeholder="All Exporters">
|
||||
{exporterFilter === 'all' ? 'All Exporters' : getCountryName(exporterFilter)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Exporters</SelectItem>
|
||||
{uniqueExporters.map((country: CountryOption) => (
|
||||
<SelectItem key={`exporter-${country.code}`} value={country.code}>
|
||||
{country.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
{(startYearFilter !== "all" || endYearFilter !== "all" || termFilter !== "all") && (
|
||||
{(startYearFilter !== "all" || endYearFilter !== "all" || termFilter !== "all" || importerFilter !== "all" || exporterFilter !== "all") && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -142,21 +201,20 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trade summary section */}
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">Trade Summary</h3>
|
||||
<h3 className="mb-2 text-xl font-semibold">Trade Summary</h3>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-md bg-background p-3 shadow-sm">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Records</h4>
|
||||
<h4 className="text-base font-medium text-muted-foreground">Records</h4>
|
||||
<p className="text-2xl font-bold">{filteredRecords.length}</p>
|
||||
{filteredRecords.length !== tradeRecords.length && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
of {tradeRecords.length} total
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md bg-background p-3 shadow-sm">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Year Range</h4>
|
||||
<h4 className="text-base font-medium text-muted-foreground">Year Range</h4>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredRecords.length > 0
|
||||
? `${startYearFilter === "all" ? yearRange.min : startYearFilter} - ${endYearFilter === "all" ? yearRange.max : endYearFilter}`
|
||||
@ -165,7 +223,7 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-background p-3 shadow-sm">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Total Quantity</h4>
|
||||
<h4 className="text-base font-medium text-muted-foreground">Total Quantity</h4>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredRecords
|
||||
.reduce((sum: number, record: CitesTradeRecord) => sum + (Number(record.quantity) || 0), 0)
|
||||
@ -175,78 +233,39 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualizations Section */}
|
||||
{visualizationData && (
|
||||
<TradeCharts
|
||||
visualizationData={visualizationData}
|
||||
PURPOSE_DESCRIPTIONS={PURPOSE_DESCRIPTIONS}
|
||||
SOURCE_DESCRIPTIONS={SOURCE_DESCRIPTIONS}
|
||||
timelineEvents={timelineEvents}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trade records table */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">Trade Records</h3>
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-muted-foreground">No records match the selected filters.</p>
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-2"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-auto">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 text-left">Year</th>
|
||||
<th className="py-2 text-left">Appendix</th>
|
||||
<th className="py-2 text-left">Term</th>
|
||||
<th className="py-2 text-left">Quantity</th>
|
||||
<th className="py-2 text-left">Unit</th>
|
||||
<th className="py-2 text-left">Importer</th>
|
||||
<th className="py-2 text-left">Exporter</th>
|
||||
<th className="py-2 text-left">Purpose</th>
|
||||
<th className="py-2 text-left">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRecords.slice(0, 20).map((record: CitesTradeRecord) => (
|
||||
<tr key={record.id} className="border-b">
|
||||
<td className="py-2">{record.year}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant="outline">
|
||||
{record.appendix}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2">{record.term}</td>
|
||||
<td className="py-2 text-right">{record.quantity || 'N/A'}</td>
|
||||
<td className="py-2">{record.unit || '-'}</td>
|
||||
<td className="py-2">{record.importer || '-'}</td>
|
||||
<td className="py-2">{record.exporter || '-'}</td>
|
||||
<td className="py-2">{record.purpose || '-'}</td>
|
||||
<td className="py-2">{record.source || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredRecords.length > 20 && (
|
||||
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Showing 20 of {filteredRecords.length} records. Use the filters to narrow down results.
|
||||
{isNarwhal && catchRecords && catchRecords.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<CatchChart catchRecords={catchRecords} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isNarwhal && catchLoading && (
|
||||
<div className="mt-8 flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="ml-2">Loading catch data...</p>
|
||||
</div>
|
||||
)}
|
||||
{isNarwhal && catchError && (
|
||||
<div className="mt-8 rounded-md bg-red-50 p-4">
|
||||
<p className="text-red-800">Error loading catch records.</p>
|
||||
<p className="mt-2 text-sm text-red-700">{catchError.message || 'Unknown error'}</p>
|
||||
</div>
|
||||
)}
|
||||
{isNarwhal && !catchLoading && !catchError && catchRecords?.length === 0 && (
|
||||
<div className="mt-8 rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-muted-foreground">No specific catch data found for Narwhal.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trade data info section */}
|
||||
<div className="rounded-md bg-blue-50 p-4 text-sm">
|
||||
<div className="rounded-md bg-blue-50 p-4 text-base mt-8">
|
||||
<h3 className="mb-2 font-medium text-blue-700">About CITES Trade Data</h3>
|
||||
<p className="text-blue-700">
|
||||
The CITES Trade Database, managed by UNEP-WCMC on behalf of the CITES Secretariat, contains records of trade in wildlife
|
||||
@ -259,13 +278,12 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">No trade records available for this species.</p>
|
||||
|
||||
{/* Add info about what trade records are */}
|
||||
<div className="rounded-md bg-blue-50 p-4 text-sm">
|
||||
<div className="rounded-md bg-blue-50 p-4 text-base">
|
||||
<h3 className="mb-2 font-medium text-blue-700">About CITES Trade Records</h3>
|
||||
<p className="text-blue-700">
|
||||
CITES trade records document international trade in wildlife listed in the CITES Appendices.
|
||||
Not all species have recorded trade data, particularly if they haven't been traded internationally
|
||||
or if trade reports haven't been submitted to the CITES Trade Database.
|
||||
Not all species have recorded trade data, particularly if they haven't been traded internationally
|
||||
or if trade reports haven't been submitted to the CITES Trade Database.
|
||||
</p>
|
||||
<p className="mt-2 text-blue-700">
|
||||
If you believe this species should have trade records, please check the following:
|
||||
|
||||
93
src/components/species/visualizations/CatchChart.tsx
Normal file
93
src/components/species/visualizations/CatchChart.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CatchRecord } from '@/lib/api'; // Assuming CatchRecord type is exported from api.ts
|
||||
import { Fish } from 'lucide-react';
|
||||
|
||||
interface CatchChartProps {
|
||||
catchRecords: CatchRecord[];
|
||||
}
|
||||
|
||||
// Helper function to format numbers for tooltip/axis
|
||||
function formatNumber(value: number | string | undefined | null): string {
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return 'N/A';
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function CatchChart({ catchRecords }: CatchChartProps) {
|
||||
// Prepare data for the chart - handle potential nulls if necessary
|
||||
// Recharts typically handles nulls by breaking the line, which is often desired.
|
||||
const chartData = catchRecords.map(record => ({
|
||||
...record,
|
||||
// Ensure numeric types for charting if needed, though Recharts might coerce
|
||||
year: Number(record.year),
|
||||
catch_total: record.catch_total !== null ? Number(record.catch_total) : null,
|
||||
quota: record.quota !== null ? Number(record.quota) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<Fish className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Catch Data Over Time (NAMMCO)</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Reported catch totals and quotas by year</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 25 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="year"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
// domain={['dataMin', 'dataMax']} // Optional: ensure axis covers full range
|
||||
/>
|
||||
<YAxis tickFormatter={formatNumber} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatNumber(value),
|
||||
name // Use default name mapping ('Catch Total', 'Quota')
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="catch_total"
|
||||
name="Catch Total"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
connectNulls={false} // Do not connect lines across null data points
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="quota"
|
||||
name="Quota"
|
||||
stroke="#82ca9d"
|
||||
strokeDasharray="5 5" // Differentiate quota line
|
||||
activeDot={{ r: 8 }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -14,17 +14,26 @@ import {
|
||||
PieChart as RechartsPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
PieLabelRenderProps,
|
||||
ReferenceLine
|
||||
} from "recharts";
|
||||
import { Payload } from 'recharts/types/component/DefaultTooltipContent';
|
||||
import React from "react";
|
||||
import { TimelineEvent } from "@/lib/api";
|
||||
|
||||
// Colors for charts
|
||||
// Simplified formatNumber for tooltip values
|
||||
const formatNumber = (value: number | string | undefined): string => {
|
||||
const num = Number(value || 0);
|
||||
return Math.round(num).toLocaleString();
|
||||
}
|
||||
|
||||
// Keep colors and basic interfaces
|
||||
const CHART_COLORS = [
|
||||
"#8884d8", "#83a6ed", "#8dd1e1", "#82ca9d", "#a4de6c",
|
||||
"#d0ed57", "#ffc658", "#ff8042", "#ff6361", "#bc5090"
|
||||
];
|
||||
|
||||
type TradeChartsProps = {
|
||||
visualizationData: {
|
||||
interface VisualizationData {
|
||||
recordsByYear: { year: number; count: number }[];
|
||||
topImporters: { country: string; count: number }[];
|
||||
topExporters: { country: string; count: number }[];
|
||||
@ -33,24 +42,63 @@ type TradeChartsProps = {
|
||||
tradeSources: { source: string; count: number; description: string }[];
|
||||
termQuantitiesByYear: { year: number; [term: string]: number }[];
|
||||
topTerms: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TradeChartsProps {
|
||||
visualizationData: VisualizationData;
|
||||
PURPOSE_DESCRIPTIONS: Record<string, string>;
|
||||
SOURCE_DESCRIPTIONS: Record<string, string>;
|
||||
timelineEvents?: TimelineEvent[];
|
||||
}
|
||||
|
||||
// Expected structure within the 'payload' object from Recharts Tooltip item for Bar Charts
|
||||
interface BarChartTooltipInternalPayload {
|
||||
purpose?: string;
|
||||
source?: string;
|
||||
description?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
// Interface for the third argument of the bar chart tooltip formatter
|
||||
interface CustomTooltipPayloadEntry extends Payload<number, string> {
|
||||
payload?: BarChartTooltipInternalPayload; // Made payload optional
|
||||
}
|
||||
|
||||
// Custom tooltip for pie chart
|
||||
const renderCustomizedLabel = ({ cx, cy, midAngle, outerRadius, percent, payload }: PieLabelRenderProps) => {
|
||||
const numCx = Number(cx || 0);
|
||||
const numCy = Number(cy || 0);
|
||||
const numOuterRadius = Number(outerRadius || 0);
|
||||
const numMidAngle = Number(midAngle || 0);
|
||||
|
||||
if (!percent || !payload) return null;
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = numOuterRadius * 1.15;
|
||||
const x = numCx + radius * Math.cos(-numMidAngle * RADIAN);
|
||||
const y = numCy + radius * Math.sin(-numMidAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="#000000"
|
||||
textAnchor={x > numCx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight="500"
|
||||
>
|
||||
{`${(payload as { term: string }).term} (${(percent * 100).toFixed(0)}%)`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DESCRIPTIONS }: TradeChartsProps) {
|
||||
// Process terms data to combine small segments
|
||||
export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DESCRIPTIONS, timelineEvents }: TradeChartsProps) {
|
||||
const processedTermsData = React.useMemo(() => {
|
||||
const threshold = 0.02; // 2% threshold
|
||||
const threshold = 0.02;
|
||||
let otherCount = 0;
|
||||
|
||||
// Sort by count in descending order
|
||||
const sortedTerms = [...visualizationData.termsTraded].sort((a, b) => b.count - a.count);
|
||||
|
||||
// Calculate total for percentage
|
||||
const total = sortedTerms.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Filter and combine small segments
|
||||
const significantTerms = sortedTerms.filter(item => {
|
||||
const percentage = item.count / total;
|
||||
if (percentage < threshold) {
|
||||
@ -59,49 +107,54 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add "Other" category if there are small segments
|
||||
if (otherCount > 0) {
|
||||
significantTerms.push({ term: 'Other', count: otherCount });
|
||||
}
|
||||
|
||||
return significantTerms;
|
||||
}, [visualizationData.termsTraded]);
|
||||
|
||||
// Custom tooltip for pie chart
|
||||
const renderCustomizedLabel = ({ cx, cy, midAngle, outerRadius, percent, payload }: any) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = outerRadius * 1.15; // Increased radius for better spacing
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
// Formatter for Purpose/Source Bar charts
|
||||
const barChartDetailFormatter = (
|
||||
value: number | string, // This is the 'count'
|
||||
name: string, // This is 'Records' (name prop from Bar component)
|
||||
entry: CustomTooltipPayloadEntry
|
||||
): [React.ReactNode, React.ReactNode] => {
|
||||
const dataPoint = entry.payload; // Actual data object for the bar, now potentially undefined
|
||||
let tooltipLabel = name; // Default to "Records"
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="#000000"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight="500"
|
||||
>
|
||||
{`${payload.term} (${(percent * 100).toFixed(0)}%)`}
|
||||
</text>
|
||||
);
|
||||
if (dataPoint?.purpose) { // Optional chaining already handles undefined dataPoint
|
||||
const purpose = dataPoint.purpose;
|
||||
const description = dataPoint.description || PURPOSE_DESCRIPTIONS[purpose] || 'Unknown';
|
||||
tooltipLabel = `${purpose} - ${description}`;
|
||||
} else if (dataPoint?.source) {
|
||||
const source = dataPoint.source;
|
||||
const description = dataPoint.description || SOURCE_DESCRIPTIONS[source] || 'Unknown';
|
||||
tooltipLabel = `${source} - ${description}`;
|
||||
}
|
||||
const formattedValue = formatNumber(value); // 'value' is already the count for the bar
|
||||
return [formattedValue, tooltipLabel];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h3 className="text-lg font-semibold">Trade Visualizations</h3>
|
||||
<h3 className="text-xl font-semibold">Trade Visualizations</h3>
|
||||
|
||||
{/* Records Over Time */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<LineChart className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Records Over Time</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
Records Over Time
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Number of trade records by year</CardDescription>
|
||||
<CardDescription>
|
||||
Number of trade records by year
|
||||
<br />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Green dashed lines indicate important timeline events. Hover over the year to see event details.
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
@ -117,8 +170,35 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis tickFormatter={formatNumber} />
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-gray-300 bg-white p-2 shadow">
|
||||
<div><strong>Year:</strong> {label}</div>
|
||||
{payload.map((entry, idx) => {
|
||||
const value = Array.isArray(entry.value) ? entry.value[0] : entry.value;
|
||||
return (
|
||||
<div key={idx}>
|
||||
<span className="font-semibold">{entry.name}:</span> {formatNumber(value)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{timelineEvents
|
||||
?.filter(e => e.year === label)
|
||||
.map((event, idx) => (
|
||||
<div key={idx} className="mt-1 border-t pt-1 text-xs">
|
||||
<div><strong>Event Type:</strong> {event.event_type}</div>
|
||||
<div><strong>Title:</strong> {event.title}</div>
|
||||
{event.description && <div><strong>Description:</strong> {event.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
@ -127,6 +207,17 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
|
||||
{timelineEvents && timelineEvents.map(event => (
|
||||
<ReferenceLine
|
||||
key={event.id}
|
||||
x={event.year}
|
||||
stroke="green"
|
||||
strokeDasharray="3 3"
|
||||
>
|
||||
</ReferenceLine>
|
||||
))}
|
||||
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@ -134,7 +225,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
</Card>
|
||||
|
||||
{/* Top Importers and Exporters */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{/* Top Importers */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@ -159,8 +250,13 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis tickFormatter={formatNumber} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatNumber(value),
|
||||
name || 'Value'
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" name="Records" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@ -192,8 +288,13 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis tickFormatter={formatNumber} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatNumber(value),
|
||||
name || 'Value'
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" name="Records" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@ -230,7 +331,12 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value, _, props) => [`${value} records`, props.payload.term]} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string, name: string) => [
|
||||
`${formatNumber(value)} records`,
|
||||
name || 'Term' // name is the nameKey (term)
|
||||
]}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@ -238,7 +344,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
</Card>
|
||||
|
||||
{/* Trade Purposes and Sources */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{/* Trade Purposes */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@ -257,7 +363,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<XAxis type="number" tickFormatter={formatNumber} />
|
||||
<YAxis
|
||||
dataKey="purpose"
|
||||
type="category"
|
||||
@ -265,10 +371,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
tickFormatter={(value) => `${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`}
|
||||
width={150}
|
||||
/>
|
||||
<Tooltip formatter={(value, _, props) => [
|
||||
`${value} records`,
|
||||
`${props.payload.purpose} - ${props.payload.description}`
|
||||
]} />
|
||||
<Tooltip formatter={barChartDetailFormatter} />
|
||||
<Bar dataKey="count" name="Records" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@ -294,7 +397,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<XAxis type="number" tickFormatter={formatNumber} />
|
||||
<YAxis
|
||||
dataKey="source"
|
||||
type="category"
|
||||
@ -302,10 +405,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
tickFormatter={(value) => `${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`}
|
||||
width={150}
|
||||
/>
|
||||
<Tooltip formatter={(value, _, props) => [
|
||||
`${value} records`,
|
||||
`${props.payload.source} - ${props.payload.description}`
|
||||
]} />
|
||||
<Tooltip formatter={barChartDetailFormatter} />
|
||||
<Bar dataKey="count" name="Records" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@ -337,15 +437,20 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis tickFormatter={formatNumber} />
|
||||
<Tooltip
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatNumber(value),
|
||||
name || 'Value' // Use name prop from <Line>
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
{visualizationData.topTerms.map((term, index) => (
|
||||
<Line
|
||||
key={term}
|
||||
type="monotone"
|
||||
dataKey={term}
|
||||
name={term}
|
||||
name={term} // This name is passed to the tooltip formatter
|
||||
stroke={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-full border px-4 py-1.5 text-base font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -15,6 +15,8 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white hover:bg-green-500/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -19,10 +19,10 @@ const buttonVariants = cva(
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-12 px-8 py-3",
|
||||
sm: "h-10 rounded-md px-4 py-2",
|
||||
lg: "h-14 rounded-md px-10 py-3",
|
||||
icon: "h-12 w-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-2 p-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-3xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-base text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
<div ref={ref} className={cn("p-8 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
|
||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
"text-xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -102,7 +102,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-base text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
176
src/components/ui/form.tsx
Normal file
176
src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
/* eslint-disable react/prop-types */ // Disable prop-types rule for this file as props are inherited
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-12 w-full rounded-md border border-input bg-background px-4 py-3 text-lg ring-offset-background file:border-0 file:bg-transparent file:text-lg file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
"text-lg font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
||||
117
src/components/ui/pagination.tsx
Normal file
117
src/components/ui/pagination.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@ -17,14 +17,14 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-12 w-full items-center justify-between rounded-md border border-input bg-background px-4 py-3 text-base ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="h-6 w-6 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
@ -42,7 +42,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
@ -59,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
@ -116,14 +116,14 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-2 pl-8 pr-3 text-base outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="h-5 w-5" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
122
src/components/ui/table.tsx
Normal file
122
src/components/ui/table.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
// eslint-disable-next-line react/prop-types
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
// eslint-disable-next-line react/prop-types
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@ -11,7 +11,8 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
// Re-apply flex-wrap and h-auto defaults, remove inline-flex and h-10
|
||||
"flex flex-wrap h-auto items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -26,7 +27,8 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
// Removed whitespace-nowrap to allow text wrapping within trigger if needed
|
||||
"inline-flex items-center justify-center rounded-sm px-4 py-2 text-xl font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
179
src/contexts/auth/AuthContext.tsx
Normal file
179
src/contexts/auth/AuthContext.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
import { supabase } from '@/lib/supabase'; // Import the shared client
|
||||
// Removed unused SupabaseUser and SupabaseSession imports
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// This will be the type for our main 'user' state
|
||||
type AppUser = Profile | null;
|
||||
|
||||
// Temporary user info derived directly from Supabase session
|
||||
interface SessionUserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
needsProfileFetch: boolean; // Flag to trigger profile fetch
|
||||
}
|
||||
|
||||
type AuthContextType = {
|
||||
user: AppUser; // Changed from User | null
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AppUser>(null);
|
||||
const [sessionUserInfo, setSessionUserInfo] = useState<SessionUserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Effect 1: Check initial session and fetch profile if session exists
|
||||
useEffect(() => {
|
||||
const checkUserSession = async () => {
|
||||
console.log('[AuthContext] checkUserSession: Checking for existing session...');
|
||||
setLoading(true); // Ensure loading is true at the start
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
console.error('[AuthContext] checkUserSession: Error getting session:', sessionError);
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session && session.user) {
|
||||
console.log('[AuthContext] checkUserSession: Session found. User ID:', session.user.id);
|
||||
try {
|
||||
const { data: profileData, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email, role')
|
||||
.eq('id', session.user.id)
|
||||
.single<Profile>();
|
||||
|
||||
if (profileError) {
|
||||
console.error('[AuthContext] checkUserSession: Error fetching profile:', profileError);
|
||||
setUser(null);
|
||||
} else if (profileData) {
|
||||
console.log('[AuthContext] checkUserSession: Profile fetched:', profileData);
|
||||
setUser(profileData);
|
||||
} else {
|
||||
console.warn('[AuthContext] checkUserSession: Profile not found for user ID:', session.user.id);
|
||||
setUser(null); // Or handle as partial user
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AuthContext] checkUserSession: Exception fetching profile:', e);
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
console.log('[AuthContext] checkUserSession: No existing session.');
|
||||
setUser(null);
|
||||
}
|
||||
console.log('[AuthContext] checkUserSession: Setting loading to false.');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkUserSession();
|
||||
}, []);
|
||||
|
||||
// Effect 2: Listen to onAuthStateChange to get basic session info
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(_event, session) => {
|
||||
console.log('[AuthContext] onAuthStateChange: Event:', _event, 'Session User ID:', session?.user?.id);
|
||||
if (_event === 'SIGNED_IN' && session && session.user) {
|
||||
setSessionUserInfo({
|
||||
id: session.user.id,
|
||||
email: session.user.email || 'No email in session',
|
||||
needsProfileFetch: true,
|
||||
});
|
||||
} else if (_event === 'SIGNED_OUT') {
|
||||
setSessionUserInfo(null);
|
||||
setUser(null); // Clear full user profile on sign out
|
||||
}
|
||||
// INITIAL_SESSION is handled by checkUserSession
|
||||
// We don't setLoading(false) here anymore, it's handled by profile fetch or checkUserSession
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Effect 3: Fetch full profile when sessionUserInfo indicates a new login
|
||||
useEffect(() => {
|
||||
if (sessionUserInfo && sessionUserInfo.needsProfileFetch) {
|
||||
const fetchFullProfile = async () => {
|
||||
console.log('[AuthContext] fetchFullProfile: Needs profile fetch for user ID:', sessionUserInfo.id);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: profileData, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email, role')
|
||||
.eq('id', sessionUserInfo.id)
|
||||
.single<Profile>();
|
||||
|
||||
if (profileError) {
|
||||
console.error('[AuthContext] fetchFullProfile: Error fetching profile:', profileError);
|
||||
setUser(null);
|
||||
} else if (profileData) {
|
||||
console.log('[AuthContext] fetchFullProfile: Profile fetched successfully:', profileData);
|
||||
setUser(profileData);
|
||||
} else {
|
||||
console.warn('[AuthContext] fetchFullProfile: Profile not found for user ID:', sessionUserInfo.id);
|
||||
setUser(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AuthContext] fetchFullProfile: Exception during profile fetch:', e);
|
||||
setUser(null);
|
||||
} finally {
|
||||
console.log('[AuthContext] fetchFullProfile: Setting loading to false.');
|
||||
setLoading(false);
|
||||
setSessionUserInfo(prev => prev ? { ...prev, needsProfileFetch: false } : null); // Reset flag
|
||||
}
|
||||
};
|
||||
fetchFullProfile();
|
||||
}
|
||||
}, [sessionUserInfo]);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
console.log('[AuthContext] signIn: Attempting to sign in with email:', email);
|
||||
// setLoading(true) will be handled by onAuthStateChange -> fetchFullProfile effect
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) {
|
||||
console.error('[AuthContext] signIn: Supabase signIn error:', error);
|
||||
// setLoading(false); // Ensure loading is reset on error
|
||||
throw error;
|
||||
}
|
||||
// Successful signIn will trigger onAuthStateChange, which then triggers profile fetch
|
||||
console.log('[AuthContext] signIn: Supabase signInWithPassword call successful. onAuthStateChange will handle next steps.');
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
console.log('[AuthContext] signOut: Signing out...');
|
||||
await supabase.auth.signOut();
|
||||
// onAuthStateChange will set user to null and sessionUserInfo to null
|
||||
console.log('[AuthContext] signOut: signOut call complete.');
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, signIn, signOut, isAdmin }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
204
src/hooks/use-toast.ts
Normal file
204
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
// Remove 'as const' and change to a type to satisfy eslint
|
||||
// const actionTypes = {
|
||||
// ADD_TOAST: "ADD_TOAST",
|
||||
// UPDATE_TOAST: "UPDATE_TOAST",
|
||||
// DISMISS_TOAST: "DISMISS_TOAST",
|
||||
// REMOVE_TOAST: "REMOVE_TOAST",
|
||||
// } as const;
|
||||
|
||||
type ActionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST";
|
||||
UPDATE_TOAST: "UPDATE_TOAST";
|
||||
DISMISS_TOAST: "DISMISS_TOAST";
|
||||
REMOVE_TOAST: "REMOVE_TOAST";
|
||||
};
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// type ActionType = typeof actionTypes;
|
||||
// Use the new ActionTypes type instead
|
||||
type ActionType = ActionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@ -44,7 +44,7 @@ export function useCitesListingCrud(speciesId: string) {
|
||||
};
|
||||
|
||||
// Handle CITES listing form changes
|
||||
const handleCitesListingFormChange = (field: string, value: any) => {
|
||||
const handleCitesListingFormChange = (field: keyof CitesListingForm, value: string | boolean) => {
|
||||
setCitesListingForm(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
|
||||
@ -53,7 +53,7 @@ export function useTimelineEventCrud(speciesId: string) {
|
||||
};
|
||||
|
||||
// Handle timeline event form changes
|
||||
const handleTimelineEventFormChange = (field: string, value: any) => {
|
||||
const handleTimelineEventFormChange = (field: keyof TimelineEventForm, value: string | number) => {
|
||||
setTimelineEventForm(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
|
||||
@ -1,20 +1,58 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { CitesTradeRecord } from "@/lib/api";
|
||||
import { useState, useMemo } from 'react';
|
||||
import { CitesTradeRecord } from '@/lib/api';
|
||||
import { getCountryName } from '@/lib/countries'; // Import the helper function
|
||||
|
||||
export type CountryOption = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefined) {
|
||||
// State for trade record filters
|
||||
const [startYearFilter, setStartYearFilter] = useState<string>("all");
|
||||
const [endYearFilter, setEndYearFilter] = useState<string>("all");
|
||||
const [termFilter, setTermFilter] = useState<string>("all");
|
||||
const [importerFilter, setImporterFilter] = useState<string>("all");
|
||||
const [exporterFilter, setExporterFilter] = useState<string>('all');
|
||||
|
||||
// Get min/max year range
|
||||
const yearRange = useMemo(() => {
|
||||
if (!tradeRecords || tradeRecords.length === 0) return { min: 0, max: 0 };
|
||||
// Get unique importers, exporters (with names), and year range
|
||||
const { uniqueImporters, uniqueExporters, yearRange } = useMemo(() => {
|
||||
if (!tradeRecords || tradeRecords.length === 0) {
|
||||
return { uniqueImporters: [], uniqueExporters: [], yearRange: { min: 0, max: 0 } };
|
||||
}
|
||||
|
||||
const years = new Set<number>();
|
||||
const importerCodes = new Set<string>();
|
||||
const exporterCodes = new Set<string>();
|
||||
|
||||
tradeRecords.forEach((r) => {
|
||||
const yearNum = Number(r.year);
|
||||
if (!isNaN(yearNum)) {
|
||||
years.add(yearNum);
|
||||
}
|
||||
if (r.importer) importerCodes.add(r.importer);
|
||||
if (r.exporter) exporterCodes.add(r.exporter);
|
||||
});
|
||||
|
||||
const sortedYears = Array.from(years).sort((a, b) => a - b);
|
||||
|
||||
// Map codes to { code, name } objects and sort by name
|
||||
const mapAndSortCountries = (codes: Set<string>): CountryOption[] => {
|
||||
return Array.from(codes)
|
||||
.map(code => ({ code, name: getCountryName(code) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const sortedImporters = mapAndSortCountries(importerCodes);
|
||||
const sortedExporters = mapAndSortCountries(exporterCodes);
|
||||
|
||||
const years = tradeRecords.map(r => Number(r.year)).filter(y => !isNaN(y));
|
||||
return {
|
||||
min: Math.min(...years),
|
||||
max: Math.max(...years)
|
||||
uniqueImporters: sortedImporters,
|
||||
uniqueExporters: sortedExporters,
|
||||
yearRange: {
|
||||
min: sortedYears[0] ?? 0,
|
||||
max: sortedYears[sortedYears.length - 1] ?? 0
|
||||
}
|
||||
};
|
||||
}, [tradeRecords]);
|
||||
|
||||
@ -29,16 +67,20 @@ export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefin
|
||||
|
||||
return (
|
||||
(recordYear >= startYearNum && recordYear <= endYearNum) &&
|
||||
(termFilter === "all" || !termFilter || record.term === termFilter)
|
||||
(termFilter === "all" || !termFilter || record.term === termFilter) &&
|
||||
(importerFilter === "all" || !importerFilter || record.importer === importerFilter) &&
|
||||
(exporterFilter === "all" || !exporterFilter || record.exporter === exporterFilter)
|
||||
);
|
||||
});
|
||||
}, [tradeRecords, startYearFilter, endYearFilter, termFilter, yearRange]);
|
||||
}, [tradeRecords, startYearFilter, endYearFilter, termFilter, importerFilter, exporterFilter, yearRange]);
|
||||
|
||||
// Reset filters function
|
||||
const resetFilters = () => {
|
||||
setStartYearFilter("all");
|
||||
setEndYearFilter("all");
|
||||
setTermFilter("all");
|
||||
setImporterFilter("all");
|
||||
setExporterFilter("all");
|
||||
};
|
||||
|
||||
return {
|
||||
@ -48,8 +90,14 @@ export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefin
|
||||
setEndYearFilter,
|
||||
termFilter,
|
||||
setTermFilter,
|
||||
importerFilter,
|
||||
setImporterFilter,
|
||||
exporterFilter,
|
||||
setExporterFilter,
|
||||
filteredRecords,
|
||||
resetFilters,
|
||||
yearRange
|
||||
yearRange,
|
||||
uniqueImporters,
|
||||
uniqueExporters
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,6 +33,16 @@
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--chart-1: 12 76% 61%;
|
||||
|
||||
--chart-2: 173 58% 39%;
|
||||
|
||||
--chart-3: 197 37% 24%;
|
||||
|
||||
--chart-4: 43 74% 66%;
|
||||
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -63,14 +73,45 @@
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* html { */
|
||||
/* Let browser default (usually 16px) be the base */
|
||||
/* font-size: 20px; */
|
||||
/* } */
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Body inherits font-size from html, no need to redeclare unless different */
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
/* Let Tailwind's utility classes handle font sizes based on the html base */
|
||||
|
||||
/* Adjust base styles for form elements if needed, but avoid !important */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button,
|
||||
.btn,
|
||||
[type="button"],
|
||||
[type="submit"] {
|
||||
/* Example: Ensure a minimum readable size, but allow utilities to override */
|
||||
/* font-size: 0.9rem; */
|
||||
/* Or leave it to default/utility classes */
|
||||
}
|
||||
|
||||
/* Let Tailwind's padding and height utilities work as intended */
|
||||
}
|
||||
|
||||
/* Removed custom override for species detail tabs wrap */
|
||||
|
||||
518
src/lib/api.ts
518
src/lib/api.ts
@ -27,6 +27,17 @@ export type SpeciesDetails = Species & {
|
||||
current_cites_listing?: CitesListing;
|
||||
};
|
||||
|
||||
// Manually define CatchRecord based on README as generated types seem outdated
|
||||
export interface CatchRecord {
|
||||
id: number; // Assuming BIGINT maps to number
|
||||
species_id: string; // UUID
|
||||
country: string | null;
|
||||
year: number;
|
||||
catch_total: number | null;
|
||||
quota: number | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export async function getAllSpecies() {
|
||||
try {
|
||||
console.log("Fetching all species from database...");
|
||||
@ -176,6 +187,29 @@ export async function getSpeciesById(id: string): Promise<SpeciesDetails | null>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total count of CITES trade records
|
||||
*/
|
||||
export async function getTotalTradeCount() {
|
||||
console.log('Fetching total trade count...');
|
||||
try {
|
||||
const { count, error } = await supabase
|
||||
.from('cites_trade_records')
|
||||
.select('*', { count: 'exact', head: true }); // Use head: true for count only
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching total trade count:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('Total trade count:', count);
|
||||
return { count: count ?? 0 }; // Return count, defaulting to 0 if null
|
||||
} catch (error) {
|
||||
console.error('Error in getTotalTradeCount:', error);
|
||||
return { count: 0 }; // Return 0 on error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTimelineEvents(speciesId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('timeline_events')
|
||||
@ -775,3 +809,487 @@ async function updateIucnAssessmentsLatest(speciesId: string, latestId: string)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// New API functions to support the enhanced species detail page
|
||||
|
||||
/**
|
||||
* Get species distribution and range data
|
||||
*/
|
||||
export async function getSpeciesDistribution(speciesId: string) {
|
||||
console.log('Fetching distribution data for species:', speciesId);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('distribution_ranges')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('region');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching distribution ranges:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Error in getSpeciesDistribution:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new distribution range entry
|
||||
*/
|
||||
export async function createDistributionRange(distribution: {
|
||||
species_id: string;
|
||||
region: string;
|
||||
presence_code: string;
|
||||
origin_code: string;
|
||||
seasonal_code?: string;
|
||||
geojson?: Record<string, unknown> | null;
|
||||
notes?: string;
|
||||
}) {
|
||||
try {
|
||||
const newDistribution = {
|
||||
...distribution,
|
||||
id: uuidv4(),
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('Creating new distribution range:', newDistribution);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('distribution_ranges')
|
||||
.insert(newDistribution)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating distribution range:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in createDistributionRange:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing distribution range entry
|
||||
*/
|
||||
export async function updateDistributionRange(id: string, updates: {
|
||||
region?: string;
|
||||
presence_code?: string;
|
||||
origin_code?: string;
|
||||
seasonal_code?: string;
|
||||
geojson?: Record<string, unknown> | null;
|
||||
notes?: string;
|
||||
}) {
|
||||
try {
|
||||
console.log(`Updating distribution range ${id}:`, updates);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('distribution_ranges')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating distribution range:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in updateDistributionRange:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a distribution range entry
|
||||
*/
|
||||
export async function deleteDistributionRange(id: string) {
|
||||
try {
|
||||
console.log(`Deleting distribution range ${id}`);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('distribution_ranges')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting distribution range:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in deleteDistributionRange:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threats for a specific species
|
||||
*/
|
||||
export async function getSpeciesThreats(speciesId: string) {
|
||||
console.log('Fetching threats data for species:', speciesId);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('species_threats')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('threat_type');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching species threats:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Error in getSpeciesThreats:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new species threat entry
|
||||
*/
|
||||
export async function createSpeciesThreat(threat: {
|
||||
species_id: string;
|
||||
threat_type: string;
|
||||
threat_code: string;
|
||||
severity?: string;
|
||||
scope?: string;
|
||||
timing?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
try {
|
||||
const newThreat = {
|
||||
...threat,
|
||||
id: uuidv4(),
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('Creating new species threat:', newThreat);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('species_threats')
|
||||
.insert(newThreat)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating species threat:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in createSpeciesThreat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing species threat entry
|
||||
*/
|
||||
export async function updateSpeciesThreat(id: string, updates: {
|
||||
threat_type?: string;
|
||||
threat_code?: string;
|
||||
severity?: string;
|
||||
scope?: string;
|
||||
timing?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
try {
|
||||
console.log(`Updating species threat ${id}:`, updates);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('species_threats')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating species threat:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in updateSpeciesThreat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a species threat entry
|
||||
*/
|
||||
export async function deleteSpeciesThreat(id: string) {
|
||||
try {
|
||||
console.log(`Deleting species threat ${id}`);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('species_threats')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting species threat:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in deleteSpeciesThreat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conservation measures for a specific species
|
||||
*/
|
||||
export async function getConservationMeasures(speciesId: string) {
|
||||
console.log('Fetching conservation measures for species:', speciesId);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conservation_measures')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('measure_type');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching conservation measures:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Error in getConservationMeasures:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new conservation measure entry
|
||||
*/
|
||||
export async function createConservationMeasure(measure: {
|
||||
species_id: string;
|
||||
measure_type: string;
|
||||
measure_code: string;
|
||||
status?: string;
|
||||
implementing_organizations?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
description?: string;
|
||||
effectiveness?: string;
|
||||
}) {
|
||||
try {
|
||||
const newMeasure = {
|
||||
...measure,
|
||||
id: uuidv4(),
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('Creating new conservation measure:', newMeasure);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conservation_measures')
|
||||
.insert(newMeasure)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating conservation measure:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in createConservationMeasure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing conservation measure entry
|
||||
*/
|
||||
export async function updateConservationMeasure(id: string, updates: {
|
||||
measure_type?: string;
|
||||
measure_code?: string;
|
||||
status?: string;
|
||||
implementing_organizations?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
description?: string;
|
||||
effectiveness?: string;
|
||||
}) {
|
||||
try {
|
||||
console.log(`Updating conservation measure ${id}:`, updates);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conservation_measures')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating conservation measure:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in updateConservationMeasure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conservation measure entry
|
||||
*/
|
||||
export async function deleteConservationMeasure(id: string) {
|
||||
try {
|
||||
console.log(`Deleting conservation measure ${id}`);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('conservation_measures')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting conservation measure:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in deleteConservationMeasure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extended species description
|
||||
* This will fetch the additional fields from the species table
|
||||
*/
|
||||
export async function getSpeciesExtendedInfo(speciesId: string) {
|
||||
console.log('Fetching extended info for species:', speciesId);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('species')
|
||||
.select(`
|
||||
description,
|
||||
habitat_description,
|
||||
population_trend,
|
||||
population_size,
|
||||
generation_length,
|
||||
movement_patterns,
|
||||
use_and_trade,
|
||||
threats_overview,
|
||||
conservation_overview
|
||||
`)
|
||||
.eq('id', speciesId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching extended species info:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in getSpeciesExtendedInfo:', error);
|
||||
return {
|
||||
description: null,
|
||||
habitat_description: null,
|
||||
population_trend: null,
|
||||
population_size: null,
|
||||
generation_length: null,
|
||||
movement_patterns: null,
|
||||
use_and_trade: null,
|
||||
threats_overview: null,
|
||||
conservation_overview: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update extended info for a species
|
||||
*/
|
||||
export async function updateSpeciesExtendedInfo(speciesId: string, updates: {
|
||||
description?: string;
|
||||
habitat_description?: string;
|
||||
population_trend?: string;
|
||||
population_size?: string;
|
||||
generation_length?: number;
|
||||
movement_patterns?: string;
|
||||
use_and_trade?: string;
|
||||
threats_overview?: string;
|
||||
conservation_overview?: string;
|
||||
}) {
|
||||
try {
|
||||
console.log(`Updating extended info for species ${speciesId}:`, updates);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('species')
|
||||
.update(updates)
|
||||
.eq('id', speciesId)
|
||||
.select(`
|
||||
description,
|
||||
habitat_description,
|
||||
population_trend,
|
||||
population_size,
|
||||
generation_length,
|
||||
movement_patterns,
|
||||
use_and_trade,
|
||||
threats_overview,
|
||||
conservation_overview
|
||||
`)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating extended species info:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in updateSpeciesExtendedInfo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add function to get catch records by species ID
|
||||
export async function getCatchRecords(speciesId: string): Promise<CatchRecord[]> {
|
||||
console.log('Fetching catch records for species ID:', speciesId);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('catch_records')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('year', { ascending: true }); // Order by year ascending for charting
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching catch records:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Fetched ${data?.length ?? 0} catch records`);
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Error in getCatchRecords:', error);
|
||||
return []; // Return empty array on error
|
||||
}
|
||||
}
|
||||
|
||||
260
src/lib/countries.ts
Normal file
260
src/lib/countries.ts
Normal file
@ -0,0 +1,260 @@
|
||||
// Mapping from ISO 3166-1 alpha-2 country codes to full names
|
||||
// Source: https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes (Partial list)
|
||||
|
||||
export const countryCodeToNameMap: Record<string, string> = {
|
||||
AF: "Afghanistan",
|
||||
AL: "Albania",
|
||||
DZ: "Algeria",
|
||||
AS: "American Samoa",
|
||||
AD: "Andorra",
|
||||
AO: "Angola",
|
||||
AI: "Anguilla",
|
||||
AQ: "Antarctica",
|
||||
AG: "Antigua and Barbuda",
|
||||
AR: "Argentina",
|
||||
AM: "Armenia",
|
||||
AW: "Aruba",
|
||||
AU: "Australia",
|
||||
AT: "Austria",
|
||||
AZ: "Azerbaijan",
|
||||
BS: "Bahamas",
|
||||
BH: "Bahrain",
|
||||
BD: "Bangladesh",
|
||||
BB: "Barbados",
|
||||
BY: "Belarus",
|
||||
BE: "Belgium",
|
||||
BZ: "Belize",
|
||||
BJ: "Benin",
|
||||
BM: "Bermuda",
|
||||
BT: "Bhutan",
|
||||
BO: "Bolivia",
|
||||
BA: "Bosnia and Herzegovina",
|
||||
BW: "Botswana",
|
||||
BR: "Brazil",
|
||||
IO: "British Indian Ocean Territory",
|
||||
BN: "Brunei Darussalam",
|
||||
BG: "Bulgaria",
|
||||
BF: "Burkina Faso",
|
||||
BI: "Burundi",
|
||||
CV: "Cabo Verde",
|
||||
KH: "Cambodia",
|
||||
CM: "Cameroon",
|
||||
CA: "Canada",
|
||||
KY: "Cayman Islands",
|
||||
CF: "Central African Republic",
|
||||
TD: "Chad",
|
||||
CL: "Chile",
|
||||
CN: "China",
|
||||
CX: "Christmas Island",
|
||||
CC: "Cocos (Keeling) Islands",
|
||||
CO: "Colombia",
|
||||
KM: "Comoros",
|
||||
CG: "Congo",
|
||||
CD: "Congo (Democratic Republic of the)",
|
||||
CK: "Cook Islands",
|
||||
CR: "Costa Rica",
|
||||
CI: "Côte d'Ivoire",
|
||||
HR: "Croatia",
|
||||
CU: "Cuba",
|
||||
CW: "Curaçao",
|
||||
CY: "Cyprus",
|
||||
CZ: "Czech Republic",
|
||||
DK: "Denmark",
|
||||
DJ: "Djibouti",
|
||||
DM: "Dominica",
|
||||
DO: "Dominican Republic",
|
||||
EC: "Ecuador",
|
||||
EG: "Egypt",
|
||||
SV: "El Salvador",
|
||||
GQ: "Equatorial Guinea",
|
||||
ER: "Eritrea",
|
||||
EE: "Estonia",
|
||||
SZ: "Eswatini",
|
||||
ET: "Ethiopia",
|
||||
FK: "Falkland Islands (Malvinas)",
|
||||
FO: "Faroe Islands",
|
||||
FJ: "Fiji",
|
||||
FI: "Finland",
|
||||
FR: "France",
|
||||
GF: "French Guiana",
|
||||
PF: "French Polynesia",
|
||||
TF: "French Southern Territories",
|
||||
GA: "Gabon",
|
||||
GM: "Gambia",
|
||||
GE: "Georgia",
|
||||
DE: "Germany",
|
||||
GH: "Ghana",
|
||||
GI: "Gibraltar",
|
||||
GR: "Greece",
|
||||
GL: "Greenland",
|
||||
GD: "Grenada",
|
||||
GP: "Guadeloupe",
|
||||
GU: "Guam",
|
||||
GT: "Guatemala",
|
||||
GG: "Guernsey",
|
||||
GN: "Guinea",
|
||||
GW: "Guinea-Bissau",
|
||||
GY: "Guyana",
|
||||
HT: "Haiti",
|
||||
HN: "Honduras",
|
||||
HK: "Hong Kong",
|
||||
HU: "Hungary",
|
||||
IS: "Iceland",
|
||||
IN: "India",
|
||||
ID: "Indonesia",
|
||||
IR: "Iran",
|
||||
IQ: "Iraq",
|
||||
IE: "Ireland",
|
||||
IM: "Isle of Man",
|
||||
IL: "Israel",
|
||||
IT: "Italy",
|
||||
JM: "Jamaica",
|
||||
JP: "Japan",
|
||||
JE: "Jersey",
|
||||
JO: "Jordan",
|
||||
KZ: "Kazakhstan",
|
||||
KE: "Kenya",
|
||||
KI: "Kiribati",
|
||||
KP: "Korea (Democratic People's Republic of)",
|
||||
KR: "Korea (Republic of)",
|
||||
KW: "Kuwait",
|
||||
KG: "Kyrgyzstan",
|
||||
LA: "Lao People's Democratic Republic",
|
||||
LV: "Latvia",
|
||||
LB: "Lebanon",
|
||||
LS: "Lesotho",
|
||||
LR: "Liberia",
|
||||
LY: "Libya",
|
||||
LI: "Liechtenstein",
|
||||
LT: "Lithuania",
|
||||
LU: "Luxembourg",
|
||||
MO: "Macao",
|
||||
MG: "Madagascar",
|
||||
MW: "Malawi",
|
||||
MY: "Malaysia",
|
||||
MV: "Maldives",
|
||||
ML: "Mali",
|
||||
MT: "Malta",
|
||||
MH: "Marshall Islands",
|
||||
MQ: "Martinique",
|
||||
MR: "Mauritania",
|
||||
MU: "Mauritius",
|
||||
YT: "Mayotte",
|
||||
MX: "Mexico",
|
||||
FM: "Micronesia (Federated States of)",
|
||||
MD: "Moldova (Republic of)",
|
||||
MC: "Monaco",
|
||||
MN: "Mongolia",
|
||||
ME: "Montenegro",
|
||||
MS: "Montserrat",
|
||||
MA: "Morocco",
|
||||
MZ: "Mozambique",
|
||||
MM: "Myanmar",
|
||||
NA: "Namibia",
|
||||
NR: "Nauru",
|
||||
NP: "Nepal",
|
||||
NL: "Netherlands",
|
||||
NC: "New Caledonia",
|
||||
NZ: "New Zealand",
|
||||
NI: "Nicaragua",
|
||||
NE: "Niger",
|
||||
NG: "Nigeria",
|
||||
NU: "Niue",
|
||||
NF: "Norfolk Island",
|
||||
MK: "North Macedonia",
|
||||
MP: "Northern Mariana Islands",
|
||||
NO: "Norway",
|
||||
OM: "Oman",
|
||||
PK: "Pakistan",
|
||||
PW: "Palau",
|
||||
PS: "Palestine, State of",
|
||||
PA: "Panama",
|
||||
PG: "Papua New Guinea",
|
||||
PY: "Paraguay",
|
||||
PE: "Peru",
|
||||
PH: "Philippines",
|
||||
PN: "Pitcairn",
|
||||
PL: "Poland",
|
||||
PT: "Portugal",
|
||||
PR: "Puerto Rico",
|
||||
QA: "Qatar",
|
||||
RE: "Réunion",
|
||||
RO: "Romania",
|
||||
RU: "Russian Federation",
|
||||
RW: "Rwanda",
|
||||
BL: "Saint Barthélemy",
|
||||
SH: "Saint Helena, Ascension and Tristan da Cunha",
|
||||
KN: "Saint Kitts and Nevis",
|
||||
LC: "Saint Lucia",
|
||||
MF: "Saint Martin (French part)",
|
||||
PM: "Saint Pierre and Miquelon",
|
||||
VC: "Saint Vincent and the Grenadines",
|
||||
WS: "Samoa",
|
||||
SM: "San Marino",
|
||||
ST: "Sao Tome and Principe",
|
||||
SA: "Saudi Arabia",
|
||||
SN: "Senegal",
|
||||
RS: "Serbia",
|
||||
SC: "Seychelles",
|
||||
SL: "Sierra Leone",
|
||||
SG: "Singapore",
|
||||
SX: "Sint Maarten (Dutch part)",
|
||||
SK: "Slovakia",
|
||||
SI: "Slovenia",
|
||||
SB: "Solomon Islands",
|
||||
SO: "Somalia",
|
||||
ZA: "South Africa",
|
||||
GS: "South Georgia and the South Sandwich Islands",
|
||||
SS: "South Sudan",
|
||||
ES: "Spain",
|
||||
LK: "Sri Lanka",
|
||||
SD: "Sudan",
|
||||
SR: "Suriname",
|
||||
SJ: "Svalbard and Jan Mayen",
|
||||
SE: "Sweden",
|
||||
CH: "Switzerland",
|
||||
SY: "Syrian Arab Republic",
|
||||
TW: "Taiwan, Province of China",
|
||||
TJ: "Tajikistan",
|
||||
TZ: "Tanzania, United Republic of",
|
||||
TH: "Thailand",
|
||||
TL: "Timor-Leste",
|
||||
TG: "Togo",
|
||||
TK: "Tokelau",
|
||||
TO: "Tonga",
|
||||
TT: "Trinidad and Tobago",
|
||||
TN: "Tunisia",
|
||||
TR: "Turkey",
|
||||
TM: "Turkmenistan",
|
||||
TC: "Turks and Caicos Islands",
|
||||
TV: "Tuvalu",
|
||||
UG: "Uganda",
|
||||
UA: "Ukraine",
|
||||
AE: "United Arab Emirates",
|
||||
GB: "United Kingdom",
|
||||
US: "United States of America",
|
||||
UM: "United States Minor Outlying Islands",
|
||||
UY: "Uruguay",
|
||||
UZ: "Uzbekistan",
|
||||
VU: "Vanuatu",
|
||||
VE: "Venezuela",
|
||||
VN: "Viet Nam",
|
||||
VG: "Virgin Islands (British)",
|
||||
VI: "Virgin Islands (U.S.)",
|
||||
WF: "Wallis and Futuna",
|
||||
EH: "Western Sahara",
|
||||
YE: "Yemen",
|
||||
ZM: "Zambia",
|
||||
ZW: "Zimbabwe",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the full country name from an ISO 3166-1 alpha-2 code.
|
||||
* Returns the code itself if no name is found.
|
||||
* @param code - The 2-letter country code.
|
||||
* @returns The full country name or the original code.
|
||||
*/
|
||||
export function getCountryName(code: string | null | undefined): string {
|
||||
if (!code) return '-'; // Handle null or undefined codes
|
||||
return countryCodeToNameMap[code.toUpperCase()] || code; // Return code if not found
|
||||
}
|
||||
@ -22,19 +22,6 @@ const client = createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
}
|
||||
});
|
||||
|
||||
// Test the connection
|
||||
(async () => {
|
||||
try {
|
||||
const { error } = await client.from('species').select('id').limit(1);
|
||||
if (error) {
|
||||
console.error('Failed to connect to Supabase:', error.message);
|
||||
} else {
|
||||
console.log('Successfully connected to Supabase');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to test Supabase connection:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Export the client
|
||||
export const supabase = client;
|
||||
// Removed the self-executing connection test
|
||||
|
||||
@ -1,49 +1,30 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export const IUCN_STATUS_COLORS: Record<string, string> = {
|
||||
'EX': 'bg-black text-white', // Extinct
|
||||
'EW': 'bg-gray-800 text-white', // Extinct in the Wild
|
||||
'CR': 'bg-red-600 text-white', // Critically Endangered
|
||||
'EN': 'bg-orange-600 text-white', // Endangered
|
||||
'VU': 'bg-yellow-500 text-black', // Vulnerable
|
||||
'NT': 'bg-yellow-300 text-black', // Near Threatened
|
||||
'LC': 'bg-green-500 text-black', // Least Concern
|
||||
'DD': 'bg-gray-500 text-white', // Data Deficient
|
||||
'NE': 'bg-gray-300 text-black', // Not Evaluated
|
||||
export const IUCN_STATUS_COLORS = {
|
||||
NE: 'bg-gray-400 text-gray-900',
|
||||
DD: 'bg-gray-500 text-white',
|
||||
LC: 'bg-green-500 text-white',
|
||||
NT: 'bg-yellow-400 text-gray-900',
|
||||
VU: 'bg-orange-500 text-white',
|
||||
EN: 'bg-red-500 text-white',
|
||||
CR: 'bg-red-700 text-white',
|
||||
EW: 'bg-purple-500 text-white',
|
||||
EX: 'bg-black text-white',
|
||||
};
|
||||
|
||||
export const IUCN_STATUS_FULL_NAMES: Record<string, string> = {
|
||||
'EX': 'Extinct',
|
||||
'EW': 'Extinct in the Wild',
|
||||
'CR': 'Critically Endangered',
|
||||
'EN': 'Endangered',
|
||||
'VU': 'Vulnerable',
|
||||
'NT': 'Near Threatened',
|
||||
'LC': 'Least Concern',
|
||||
'DD': 'Data Deficient',
|
||||
'NE': 'Not Evaluated',
|
||||
export const IUCN_STATUS_PROGRESS = {
|
||||
NE: 0,
|
||||
DD: 10,
|
||||
LC: 25,
|
||||
NT: 40,
|
||||
VU: 55,
|
||||
EN: 70,
|
||||
CR: 85,
|
||||
EW: 95,
|
||||
EX: 100,
|
||||
};
|
||||
|
||||
export const CITES_APPENDIX_COLORS: Record<string, string> = {
|
||||
'I': 'bg-red-600 text-white', // Appendix I
|
||||
'II': 'bg-blue-600 text-white', // Appendix II
|
||||
'III': 'bg-green-600 text-white', // Appendix III
|
||||
};
|
||||
|
||||
export function truncateString(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength) + '...';
|
||||
}
|
||||
161
src/pages/admin/cites-listings/create.tsx
Normal file
161
src/pages/admin/cites-listings/create.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { CitesListingForm } from '@/components/admin/CitesListingForm';
|
||||
import { citesListingsApi, speciesApi, CitesListing, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function CreateCitesListing() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string | null>(
|
||||
searchParams.get('species_id')
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch all species for the dropdown
|
||||
const { data: species, isLoading: speciesLoading } = useQuery({
|
||||
queryKey: ['admin', 'species', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await speciesApi.getAll(1, 500); // Fetch up to 500 species
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// If a species_id was provided in the URL params, fetch that species details
|
||||
const { data: selectedSpecies } = useQuery({
|
||||
queryKey: ['admin', 'species', selectedSpeciesId],
|
||||
queryFn: () => selectedSpeciesId ? speciesApi.getById(selectedSpeciesId) : null,
|
||||
enabled: !!selectedSpeciesId
|
||||
});
|
||||
|
||||
// Create CITES listing mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CitesListing) => citesListingsApi.create(data),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'CITES listing has been created successfully.',
|
||||
});
|
||||
|
||||
// Navigate back to species detail page if we came from there
|
||||
// if (selectedSpeciesId) {
|
||||
// navigate(`/admin/species/${selectedSpeciesId}`);
|
||||
// } else {
|
||||
// navigate('/admin/cites-listings');
|
||||
// }
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to create CITES listing: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: CitesListing) => {
|
||||
setIsSubmitting(true);
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleSpeciesChange = (value: string) => {
|
||||
setSelectedSpeciesId(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/cites-listings')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to CITES Listings
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Add New CITES Listing</h1>
|
||||
<p className="text-muted-foreground">Create a new CITES appendix listing</p>
|
||||
</div>
|
||||
|
||||
{/* Species selection dropdown (only if not pre-selected) */}
|
||||
{!searchParams.get('species_id') && (
|
||||
<div className="mb-6 rounded-md border p-6">
|
||||
<Label htmlFor="species-select" className="text-lg font-medium">
|
||||
Select Species
|
||||
</Label>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Choose the species for this CITES listing
|
||||
</p>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
value={selectedSpeciesId || ''}
|
||||
onValueChange={handleSpeciesChange}
|
||||
disabled={speciesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a species" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{species?.map((s: Species) => (
|
||||
<SelectItem key={s.id} value={s.id!}>
|
||||
<span className="italic">{s.scientific_name}</span>
|
||||
{' - '}
|
||||
<span>{s.common_name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show the form if a species is selected */}
|
||||
{selectedSpeciesId ? (
|
||||
<div className="rounded-md border p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{selectedSpecies ? (
|
||||
<>
|
||||
CITES Listing for <span className="italic">{selectedSpecies.scientific_name}</span>
|
||||
</>
|
||||
) : (
|
||||
'New CITES Listing'
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<CitesListingForm
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
speciesId={selectedSpeciesId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!searchParams.get('species_id') && (
|
||||
<div className="rounded-md border p-6 text-center">
|
||||
<p className="text-muted-foreground">Please select a species to continue</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
141
src/pages/admin/cites-listings/edit.tsx
Normal file
141
src/pages/admin/cites-listings/edit.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { CitesListingForm } from '@/components/admin/CitesListingForm';
|
||||
import { citesListingsApi, speciesApi, CitesListing } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
export default function EditCitesListing() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch the CITES listing data
|
||||
const { data: listing, isLoading: listingLoading, error: listingError } = useQuery({
|
||||
queryKey: ['admin', 'cites-listing', id],
|
||||
queryFn: async () => {
|
||||
// Since there's no direct API to get a listing by ID in our current API,
|
||||
// we would need to implement this in a real application.
|
||||
// For now, let's simulate by fetching all listings for the species and finding the right one
|
||||
|
||||
// This is a workaround for demo purposes
|
||||
// In a real app, you would add a getById method to citesListingsApi
|
||||
|
||||
// First, we need to get all species
|
||||
const { data: allSpecies } = await speciesApi.getAll(1, 100);
|
||||
|
||||
// Then for each species, get its listings and find the one with matching ID
|
||||
for (const species of allSpecies) {
|
||||
const listings = await citesListingsApi.getBySpeciesId(species.id!);
|
||||
const foundListing = listings.find(l => l.id === id);
|
||||
|
||||
if (foundListing) {
|
||||
// Attach the species data to the listing for UI display
|
||||
return {
|
||||
...foundListing,
|
||||
species
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('CITES listing not found');
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Fetch species data to display in the form context
|
||||
const { data: species, isLoading: speciesLoading } = useQuery({
|
||||
queryKey: ['admin', 'species', listing?.species_id],
|
||||
queryFn: () => listing?.species_id ? speciesApi.getById(listing.species_id) : null,
|
||||
enabled: !!listing?.species_id,
|
||||
});
|
||||
|
||||
// Update CITES listing mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CitesListing) => id ? citesListingsApi.update(id, data) : Promise.reject('Listing ID is required'),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'CITES listing has been updated successfully.',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'cites-listing'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'cites-listings'] });
|
||||
// navigate('/admin/cites-listings');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to update CITES listing: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: CitesListing) => {
|
||||
setIsSubmitting(true);
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
const isLoading = listingLoading || speciesLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex justify-center p-8">Loading CITES listing data...</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (listingError) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading CITES listing: {(listingError as Error).message}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/cites-listings')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to CITES Listings
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Edit CITES Listing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{species && (
|
||||
<>
|
||||
Edit CITES Appendix {listing?.appendix} listing for <span className="italic">{species.scientific_name}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-6">
|
||||
{listing && (
|
||||
<CitesListingForm
|
||||
initialData={listing}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
272
src/pages/admin/cites-listings/list.tsx
Normal file
272
src/pages/admin/cites-listings/list.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Pencil, Trash, Search } from 'lucide-react';
|
||||
import { citesListingsApi, speciesApi, CitesListing, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { format } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function CitesListingsList() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { toast } = useToast();
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState<{open: boolean, listing?: CitesListing}>({open: false});
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Data fetching for listings
|
||||
const { data: listings, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'cites-listings'],
|
||||
queryFn: async () => {
|
||||
// Fetch all listings
|
||||
// In a real-world scenario, you would implement pagination and more complex filtering
|
||||
const speciesData = await speciesApi.getAll(1, 100); // Get all species (limit 100)
|
||||
|
||||
// For each species, fetch its CITES listings
|
||||
const allListings: (CitesListing & { species?: Species })[] = [];
|
||||
|
||||
await Promise.all(speciesData.data.map(async (species) => {
|
||||
const listings = await citesListingsApi.getBySpeciesId(species.id!);
|
||||
// Attach species data to each listing for display
|
||||
const listingsWithSpecies = listings.map(listing => ({
|
||||
...listing,
|
||||
species,
|
||||
}));
|
||||
allListings.push(...listingsWithSpecies);
|
||||
}));
|
||||
|
||||
return allListings;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter the listings based on search query
|
||||
const filteredListings = listings && searchQuery
|
||||
? listings.filter(listing =>
|
||||
listing.species?.scientific_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
listing.species?.common_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
listing.appendix.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: listings;
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => citesListingsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Listing deleted',
|
||||
description: 'The CITES listing has been successfully deleted.',
|
||||
});
|
||||
queryClient.invalidateQueries({queryKey: ['admin', 'cites-listings']});
|
||||
setDeleteDialog({open: false});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to delete listing. ${(err as Error).message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSearching(!!searchQuery);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
// Open delete dialog
|
||||
const confirmDelete = (listing: CitesListing) => {
|
||||
setDeleteDialog({ open: true, listing });
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = () => {
|
||||
if (deleteDialog.listing?.id) {
|
||||
deleteMutation.mutate(deleteDialog.listing.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">CITES Listings</h1>
|
||||
<p className="text-muted-foreground">Manage CITES appendix listings for species</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/admin/cites-listings/create">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Listing
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by species name or appendix..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{isSearching && (
|
||||
<Button variant="outline" onClick={clearSearch}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Listings table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-8">Loading CITES listings...</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading listings: {(error as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Species</TableHead>
|
||||
<TableHead>Appendix</TableHead>
|
||||
<TableHead>Listing Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredListings && filteredListings.length > 0 ? (
|
||||
filteredListings.map((listing) => (
|
||||
<TableRow key={listing.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium italic">{listing.species?.scientific_name}</p>
|
||||
<p className="text-sm text-muted-foreground">{listing.species?.common_name}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={
|
||||
listing.appendix === 'I' ? 'destructive' :
|
||||
listing.appendix === 'II' ? 'default' : 'outline'
|
||||
}>
|
||||
Appendix {listing.appendix}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(listing.listing_date)}</TableCell>
|
||||
<TableCell>
|
||||
{listing.is_current ? (
|
||||
<Badge variant="success">Current</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Historical</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/admin/cites-listings/${listing.id}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={() => confirmDelete(listing)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
No CITES listings found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete CITES Listing</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete the CITES Appendix {deleteDialog.listing?.appendix} listing for{' '}
|
||||
<span className="font-medium italic">{
|
||||
listings?.find(l => l.id === deleteDialog.listing?.id)?.species?.scientific_name
|
||||
}</span>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialog({ open: false })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
161
src/pages/admin/common-names/create.tsx
Normal file
161
src/pages/admin/common-names/create.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { CommonNameForm } from '@/components/admin/CommonNameForm';
|
||||
import { commonNamesApi, speciesApi, CommonName, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function CreateCommonName() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string | null>(
|
||||
searchParams.get('species_id')
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch all species for the dropdown
|
||||
const { data: species, isLoading: speciesLoading } = useQuery({
|
||||
queryKey: ['admin', 'species', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await speciesApi.getAll(1, 500); // Fetch up to 500 species
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// If a species_id was provided in the URL params, fetch that species details
|
||||
const { data: selectedSpecies } = useQuery({
|
||||
queryKey: ['admin', 'species', selectedSpeciesId],
|
||||
queryFn: () => selectedSpeciesId ? speciesApi.getById(selectedSpeciesId) : null,
|
||||
enabled: !!selectedSpeciesId
|
||||
});
|
||||
|
||||
// Create common name mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CommonName) => commonNamesApi.create(data),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Common name has been added successfully.',
|
||||
});
|
||||
|
||||
// Navigate back to species detail page if we came from there
|
||||
// if (selectedSpeciesId) {
|
||||
// navigate(`/admin/species/${selectedSpeciesId}`);
|
||||
// } else {
|
||||
// navigate('/admin/common-names');
|
||||
// }
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to create common name: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: CommonName) => {
|
||||
setIsSubmitting(true);
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleSpeciesChange = (value: string) => {
|
||||
setSelectedSpeciesId(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/common-names')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Common Names
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Add New Common Name</h1>
|
||||
<p className="text-muted-foreground">Create a new common name for a species</p>
|
||||
</div>
|
||||
|
||||
{/* Species selection dropdown (only if not pre-selected) */}
|
||||
{!searchParams.get('species_id') && (
|
||||
<div className="mb-6 rounded-md border p-6">
|
||||
<Label htmlFor="species-select" className="text-lg font-medium">
|
||||
Select Species
|
||||
</Label>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Choose the species for this common name
|
||||
</p>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
value={selectedSpeciesId || ''}
|
||||
onValueChange={handleSpeciesChange}
|
||||
disabled={speciesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a species" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{species?.map((s: Species) => (
|
||||
<SelectItem key={s.id} value={s.id!}>
|
||||
<span className="italic">{s.scientific_name}</span>
|
||||
{' - '}
|
||||
<span>{s.common_name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show the form if a species is selected */}
|
||||
{selectedSpeciesId ? (
|
||||
<div className="rounded-md border p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{selectedSpecies ? (
|
||||
<>
|
||||
New Common Name for <span className="italic">{selectedSpecies.scientific_name}</span>
|
||||
</>
|
||||
) : (
|
||||
'New Common Name'
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<CommonNameForm
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
speciesId={selectedSpeciesId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!searchParams.get('species_id') && (
|
||||
<div className="rounded-md border p-6 text-center">
|
||||
<p className="text-muted-foreground">Please select a species to continue</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
118
src/pages/admin/common-names/edit.tsx
Normal file
118
src/pages/admin/common-names/edit.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { CommonNameForm } from '@/components/admin/CommonNameForm';
|
||||
import { commonNamesApi, speciesApi, CommonName } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
export default function EditCommonName() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch the common name data
|
||||
const { data: commonName, isLoading: commonNameLoading, error: commonNameError } = useQuery({
|
||||
queryKey: ['admin', 'common-name', id],
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error('Common Name ID is required');
|
||||
return await commonNamesApi.getById(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Fetch species data to display in the form context
|
||||
const { data: species, isLoading: speciesLoading } = useQuery({
|
||||
queryKey: ['admin', 'species', commonName?.species_id],
|
||||
queryFn: () => commonName?.species_id ? speciesApi.getById(commonName.species_id) : null,
|
||||
enabled: !!commonName?.species_id,
|
||||
});
|
||||
|
||||
// Update common name mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CommonName) => id ? commonNamesApi.update(id, data) : Promise.reject('Common Name ID is required'),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Common name has been updated successfully.',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'common-name'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'common-names'] });
|
||||
// navigate('/admin/common-names');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to update common name: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: CommonName) => {
|
||||
setIsSubmitting(true);
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
const isLoading = commonNameLoading || speciesLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex justify-center p-8">Loading common name data...</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (commonNameError) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading common name: {(commonNameError as Error).message}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/common-names')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Common Names
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Edit Common Name</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{species && (
|
||||
<>
|
||||
Edit common name "{commonName?.name}" for <span className="italic">{species.scientific_name}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-6">
|
||||
{commonName && (
|
||||
<CommonNameForm
|
||||
initialData={commonName}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
277
src/pages/admin/common-names/list.tsx
Normal file
277
src/pages/admin/common-names/list.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Pencil, Trash, Search, Flag } from 'lucide-react';
|
||||
import { commonNamesApi, CommonName, Species } from '@/services/adminApi'; // Added Species import
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// Define a type that includes the nested species object
|
||||
type CommonNameWithSpecies = CommonName & { species?: Species };
|
||||
|
||||
export default function CommonNamesList() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState<{open: boolean, commonName?: CommonNameWithSpecies }>({open: false}); // Use CommonNameWithSpecies
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Data fetching for common names
|
||||
const { data: commonNames, isLoading, error } = useQuery<CommonNameWithSpecies[], Error>({ // Use CommonNameWithSpecies[]
|
||||
queryKey: ['admin', 'common-names'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Fetch paginated common names with species information
|
||||
const { data } = await commonNamesApi.getAll(1, 100); // Limit to 100 for simplicity
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching common names:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter the common names based on search query
|
||||
const filteredCommonNames = commonNames && searchQuery
|
||||
? commonNames.filter(name =>
|
||||
name.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
name.language.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(name.species?.scientific_name && // Check if scientific_name exists
|
||||
name.species.scientific_name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
: commonNames;
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => commonNamesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Common name deleted',
|
||||
description: 'The common name has been successfully deleted.',
|
||||
});
|
||||
queryClient.invalidateQueries({queryKey: ['admin', 'common-names']});
|
||||
setDeleteDialog({open: false});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to delete common name. ${(err as Error).message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSearching(!!searchQuery);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
// Open delete dialog
|
||||
const confirmDelete = (commonName: CommonNameWithSpecies) => { // Use CommonNameWithSpecies
|
||||
setDeleteDialog({ open: true, commonName });
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = () => {
|
||||
if (deleteDialog.commonName?.id) {
|
||||
deleteMutation.mutate(deleteDialog.commonName.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Map language codes to language names for display
|
||||
const getLanguageName = (code: string) => {
|
||||
const languageMap: Record<string, string> = {
|
||||
'en': 'English',
|
||||
'fr': 'French',
|
||||
'es': 'Spanish',
|
||||
'ru': 'Russian',
|
||||
'de': 'German',
|
||||
'zh': 'Chinese',
|
||||
'ja': 'Japanese',
|
||||
'inuktitut': 'Inuktitut',
|
||||
'greenlandic': 'Greenlandic',
|
||||
'norwegian': 'Norwegian',
|
||||
'swedish': 'Swedish',
|
||||
'finnish': 'Finnish',
|
||||
'danish': 'Danish',
|
||||
'icelandic': 'Icelandic',
|
||||
'faroese': 'Faroese',
|
||||
'sami': 'Sami',
|
||||
};
|
||||
|
||||
return languageMap[code] || code;
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Common Names</h1>
|
||||
<p className="text-muted-foreground">Manage common names for species in different languages</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/admin/common-names/create">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Common Name
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, language, or species..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{isSearching && (
|
||||
<Button variant="outline" onClick={clearSearch}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Common names table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-8">Loading common names...</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading common names: {(error as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Common Name</TableHead>
|
||||
<TableHead>Language</TableHead>
|
||||
<TableHead>Species</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCommonNames && filteredCommonNames.length > 0 ? (
|
||||
filteredCommonNames.map((commonName) => (
|
||||
<TableRow key={commonName.id}>
|
||||
<TableCell className="font-medium">
|
||||
{commonName.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getLanguageName(commonName.language)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{commonName.species && (
|
||||
<div>
|
||||
<p className="italic">{commonName.species.scientific_name}</p>
|
||||
<p className="text-sm text-muted-foreground">{commonName.species.common_name}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{commonName.is_main && (
|
||||
<Badge className="bg-blue-500">
|
||||
<Flag className="mr-1 h-3 w-3" /> Main
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/admin/common-names/${commonName.id}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={() => confirmDelete(commonName)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
No common names found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Common Name</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialog.commonName?.name}" ({getLanguageName(deleteDialog.commonName?.language || '')}) for{' '}
|
||||
<span className="font-medium italic">{deleteDialog.commonName?.species?.scientific_name}</span>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialog({ open: false })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
126
src/pages/admin/dashboard.tsx
Normal file
126
src/pages/admin/dashboard.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
// CardDescription, // Not used in StatsCard, can be removed if not used elsewhere
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { BarChart, Layers, FileCheck, GitBranch } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase'; // Import Supabase client
|
||||
import { speciesApi } from '@/services/adminApi'; // Import speciesApi
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
totalSpecies: 0,
|
||||
totalTradeRecords: 0,
|
||||
totalIucnAssessments: 0,
|
||||
totalCitesListings: 0,
|
||||
});
|
||||
const [loadingStats, setLoadingStats] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
console.log('[Dashboard] Fetching stats...');
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
// Using Promise.all to fetch all counts concurrently
|
||||
const [
|
||||
speciesRes,
|
||||
tradeRecordsRes,
|
||||
iucnAssessmentsRes,
|
||||
citesListingsRes,
|
||||
] = await Promise.all([
|
||||
speciesApi.getAll(1, 1), // Fetches total count for species from speciesApi
|
||||
supabase.from('cites_trade_records').select('*', { count: 'exact', head: true }), // Corrected table name
|
||||
supabase.from('iucn_assessments').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('cites_listings').select('*', { count: 'exact', head: true }),
|
||||
]);
|
||||
|
||||
// Log errors if any, but still try to set counts for successful fetches
|
||||
// speciesApi.getAll() throws on error, so speciesRes won't have an .error property here if successful.
|
||||
// Errors from speciesApi.getAll() will be caught by the main try...catch block.
|
||||
if (tradeRecordsRes.error) console.error('[Dashboard] Error fetching CITES trade records count:', tradeRecordsRes.error);
|
||||
if (iucnAssessmentsRes.error) console.error('[Dashboard] Error fetching IUCN assessments count:', iucnAssessmentsRes.error);
|
||||
if (citesListingsRes.error) console.error('[Dashboard] Error fetching CITES listings count:', citesListingsRes.error);
|
||||
|
||||
setStats({
|
||||
totalSpecies: speciesRes.count || 0, // speciesRes.count is available if speciesApi.getAll() succeeded
|
||||
totalTradeRecords: tradeRecordsRes.count || 0,
|
||||
totalIucnAssessments: iucnAssessmentsRes.count || 0,
|
||||
totalCitesListings: citesListingsRes.count || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error in fetchStats Promise.all:', error);
|
||||
// In case of a major error in Promise.all itself, stats will remain 0
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
console.log('[Dashboard] Finished fetching stats. Current stats:', stats); // Log current stats after fetch
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []); // Empty dependency array ensures this runs once on mount
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
||||
<p className="text-muted-foreground">Manage your Arctic species database</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Species"
|
||||
value={loadingStats ? 'Loading...' : stats.totalSpecies}
|
||||
description="Species in database"
|
||||
icon={<Layers className="h-6 w-6 text-blue-500" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="CITES Trade Records"
|
||||
value={loadingStats ? 'Loading...' : stats.totalTradeRecords}
|
||||
description="Trade records tracked"
|
||||
icon={<BarChart className="h-6 w-6 text-green-500" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="IUCN Assessments"
|
||||
value={loadingStats ? 'Loading...' : stats.totalIucnAssessments}
|
||||
description="Conservation status assessments"
|
||||
icon={<FileCheck className="h-6 w-6 text-red-500" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="CITES Listings"
|
||||
value={loadingStats ? 'Loading...' : stats.totalCitesListings}
|
||||
description="CITES appendix listings"
|
||||
icon={<GitBranch className="h-6 w-6 text-purple-500" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TODO: Add loading indicator for the whole stats section if loadingStats is true */}
|
||||
{/* Recent activity, quick links or other dashboard elements */}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsCard({ title, value, description, icon }: StatsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
161
src/pages/admin/iucn-assessments/create.tsx
Normal file
161
src/pages/admin/iucn-assessments/create.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { IucnAssessmentForm } from '@/components/admin/IucnAssessmentForm';
|
||||
import { iucnAssessmentsApi, speciesApi, IucnAssessment, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function CreateIucnAssessment() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string | null>(
|
||||
searchParams.get('species_id')
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch all species for the dropdown
|
||||
const { data: species, isLoading: speciesLoading } = useQuery({
|
||||
queryKey: ['admin', 'species', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await speciesApi.getAll(1, 500); // Fetch up to 500 species
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// If a species_id was provided in the URL params, fetch that species details
|
||||
const { data: selectedSpecies } = useQuery({
|
||||
queryKey: ['admin', 'species', selectedSpeciesId],
|
||||
queryFn: () => selectedSpeciesId ? speciesApi.getById(selectedSpeciesId) : null,
|
||||
enabled: !!selectedSpeciesId
|
||||
});
|
||||
|
||||
// Create IUCN assessment mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: IucnAssessment) => iucnAssessmentsApi.create(data),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'IUCN assessment has been created successfully.',
|
||||
});
|
||||
|
||||
// Navigate back to species detail page if we came from there
|
||||
// if (selectedSpeciesId) {
|
||||
// navigate(`/admin/species/${selectedSpeciesId}`);
|
||||
// } else {
|
||||
// navigate('/admin/iucn-assessments');
|
||||
// }
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to create IUCN assessment: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: IucnAssessment) => {
|
||||
setIsSubmitting(true);
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleSpeciesChange = (value: string) => {
|
||||
setSelectedSpeciesId(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/iucn-assessments')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to IUCN Assessments
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Add New IUCN Assessment</h1>
|
||||
<p className="text-muted-foreground">Create a new IUCN Red List assessment</p>
|
||||
</div>
|
||||
|
||||
{/* Species selection dropdown (only if not pre-selected) */}
|
||||
{!searchParams.get('species_id') && (
|
||||
<div className="mb-6 rounded-md border p-6">
|
||||
<Label htmlFor="species-select" className="text-lg font-medium">
|
||||
Select Species
|
||||
</Label>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Choose the species for this IUCN assessment
|
||||
</p>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
value={selectedSpeciesId || ''}
|
||||
onValueChange={handleSpeciesChange}
|
||||
disabled={speciesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a species" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{species?.map((s: Species) => (
|
||||
<SelectItem key={s.id} value={s.id!}>
|
||||
<span className="italic">{s.scientific_name}</span>
|
||||
{' - '}
|
||||
<span>{s.common_name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show the form if a species is selected */}
|
||||
{selectedSpeciesId ? (
|
||||
<div className="rounded-md border p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{selectedSpecies ? (
|
||||
<>
|
||||
IUCN Assessment for <span className="italic">{selectedSpecies.scientific_name}</span>
|
||||
</>
|
||||
) : (
|
||||
'New IUCN Assessment'
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<IucnAssessmentForm
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
speciesId={selectedSpeciesId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!searchParams.get('species_id') && (
|
||||
<div className="rounded-md border p-6 text-center">
|
||||
<p className="text-muted-foreground">Please select a species to continue</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
141
src/pages/admin/iucn-assessments/edit.tsx
Normal file
141
src/pages/admin/iucn-assessments/edit.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { IucnAssessmentForm } from '@/components/admin/IucnAssessmentForm';
|
||||
import { iucnAssessmentsApi, speciesApi, IucnAssessment } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
export default function EditIucnAssessment() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch the IUCN assessment data
|
||||
const { data: assessment, isLoading: assessmentLoading, error: assessmentError } = useQuery({
|
||||
queryKey: ['admin', 'iucn-assessment', id],
|
||||
queryFn: async () => {
|
||||
// Since there's no direct API to get an assessment by ID in our current API,
|
||||
// we would need to implement this in a real application.
|
||||
// For now, let's simulate by fetching all assessments for the species and finding the right one
|
||||
|
||||
// This is a workaround for demo purposes
|
||||
// In a real app, you would add a getById method to iucnAssessmentsApi
|
||||
|
||||
// First, we need to get all species
|
||||
const { data: allSpecies } = await speciesApi.getAll(1, 100);
|
||||
|
||||
// Then for each species, get its assessments and find the one with matching ID
|
||||
for (const species of allSpecies) {
|
||||
const assessments = await iucnAssessmentsApi.getBySpeciesId(species.id!);
|
||||
const foundAssessment = assessments.find(a => a.id === id);
|
||||
|
||||
if (foundAssessment) {
|
||||
// Attach the species data to the assessment for UI display
|
||||
return {
|
||||
...foundAssessment,
|
||||
species
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('IUCN assessment not found');
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Fetch species data to display in the form context
|
||||
const { data: species, isLoading: speciesLoading } = useQuery({
|
||||
queryKey: ['admin', 'species', assessment?.species_id],
|
||||
queryFn: () => assessment?.species_id ? speciesApi.getById(assessment.species_id) : null,
|
||||
enabled: !!assessment?.species_id,
|
||||
});
|
||||
|
||||
// Update IUCN assessment mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: IucnAssessment) => id ? iucnAssessmentsApi.update(id, data) : Promise.reject('Assessment ID is required'),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'IUCN assessment has been updated successfully.',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'iucn-assessment'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'iucn-assessments'] });
|
||||
// navigate('/admin/iucn-assessments');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to update IUCN assessment: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: IucnAssessment) => {
|
||||
setIsSubmitting(true);
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
const isLoading = assessmentLoading || speciesLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex justify-center p-8">Loading IUCN assessment data...</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (assessmentError) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading IUCN assessment: {(assessmentError as Error).message}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/iucn-assessments')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to IUCN Assessments
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Edit IUCN Assessment</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{species && (
|
||||
<>
|
||||
Edit {assessment?.status} assessment for <span className="italic">{species.scientific_name}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-6">
|
||||
{assessment && (
|
||||
<IucnAssessmentForm
|
||||
initialData={assessment}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
298
src/pages/admin/iucn-assessments/list.tsx
Normal file
298
src/pages/admin/iucn-assessments/list.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Pencil, Trash, Search, ExternalLink } from 'lucide-react';
|
||||
import { iucnAssessmentsApi, speciesApi, IucnAssessment, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// IUCN Status badges with appropriate styling
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { label: string; variant: string }> = {
|
||||
EX: { label: 'Extinct', variant: 'destructive' },
|
||||
EW: { label: 'Extinct in the Wild', variant: 'destructive' },
|
||||
CR: { label: 'Critically Endangered', variant: 'destructive' },
|
||||
EN: { label: 'Endangered', variant: 'destructive' },
|
||||
VU: { label: 'Vulnerable', variant: 'default' },
|
||||
NT: { label: 'Near Threatened', variant: 'default' },
|
||||
LC: { label: 'Least Concern', variant: 'success' },
|
||||
DD: { label: 'Data Deficient', variant: 'outline' },
|
||||
NE: { label: 'Not Evaluated', variant: 'outline' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || { label: status, variant: 'outline' };
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant as "default" | "secondary" | "destructive" | "outline" | "success" | null | undefined}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default function IucnAssessmentsList() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState<{open: boolean, assessment?: IucnAssessment}>({open: false});
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Data fetching for assessments
|
||||
const { data: assessments, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'iucn-assessments'],
|
||||
queryFn: async () => {
|
||||
// Fetch all species (limit 100)
|
||||
const speciesData = await speciesApi.getAll(1, 100);
|
||||
|
||||
// For each species, fetch its IUCN assessments
|
||||
const allAssessments: (IucnAssessment & { species?: Species })[] = [];
|
||||
|
||||
await Promise.all(speciesData.data.map(async (species) => {
|
||||
const assessments = await iucnAssessmentsApi.getBySpeciesId(species.id!);
|
||||
// Attach species data to each assessment for display
|
||||
const assessmentsWithSpecies = assessments.map(assessment => ({
|
||||
...assessment,
|
||||
species,
|
||||
}));
|
||||
allAssessments.push(...assessmentsWithSpecies);
|
||||
}));
|
||||
|
||||
return allAssessments;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter the assessments based on search query
|
||||
const filteredAssessments = assessments && searchQuery
|
||||
? assessments.filter(assessment =>
|
||||
assessment.species?.scientific_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
assessment.species?.common_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
assessment.status.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: assessments;
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => iucnAssessmentsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Assessment deleted',
|
||||
description: 'The IUCN assessment has been successfully deleted.',
|
||||
});
|
||||
queryClient.invalidateQueries({queryKey: ['admin', 'iucn-assessments']});
|
||||
setDeleteDialog({open: false});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to delete assessment. ${(err as Error).message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSearching(!!searchQuery);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
// Open delete dialog
|
||||
const confirmDelete = (assessment: IucnAssessment) => {
|
||||
setDeleteDialog({ open: true, assessment });
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = () => {
|
||||
if (deleteDialog.assessment?.id) {
|
||||
deleteMutation.mutate(deleteDialog.assessment.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">IUCN Assessments</h1>
|
||||
<p className="text-muted-foreground">Manage IUCN Red List assessments for species</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/admin/iucn-assessments/create">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Assessment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by species name or status..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{isSearching && (
|
||||
<Button variant="outline" onClick={clearSearch}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Assessments table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-8">Loading IUCN assessments...</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading assessments: {(error as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Species</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Flags</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAssessments && filteredAssessments.length > 0 ? (
|
||||
filteredAssessments.map((assessment) => (
|
||||
<TableRow key={assessment.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium italic">{assessment.species?.scientific_name}</p>
|
||||
<p className="text-sm text-muted-foreground">{assessment.species?.common_name}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(assessment.status)}
|
||||
{assessment.is_latest && (
|
||||
<span className="ml-2 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{assessment.year_published}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{assessment.possibly_extinct && (
|
||||
<Badge variant="destructive" className="mr-1">Possibly Extinct</Badge>
|
||||
)}
|
||||
{assessment.possibly_extinct_in_wild && (
|
||||
<Badge variant="destructive">Possibly Extinct in Wild</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
{assessment.url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
asChild
|
||||
>
|
||||
<a href={assessment.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/admin/iucn-assessments/${assessment.id}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={() => confirmDelete(assessment)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
No IUCN assessments found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete IUCN Assessment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete the {deleteDialog.assessment?.status} IUCN assessment for{' '}
|
||||
<span className="font-medium italic">{
|
||||
assessments?.find(a => a.id === deleteDialog.assessment?.id)?.species?.scientific_name
|
||||
}</span>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialog({ open: false })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
22
src/pages/admin/login.tsx
Normal file
22
src/pages/admin/login.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
import { useAuth } from '@/contexts/auth/AuthContext';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { user, isAdmin } = useAuth();
|
||||
|
||||
// If user is already logged in and is an admin, redirect to dashboard
|
||||
if (user && isAdmin) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
// If user is logged in but not an admin, perhaps redirect to home or show an access denied message
|
||||
// For now, we'll let them see the login form again, or they could be stuck in a redirect loop if AdminRoute also redirects non-admins.
|
||||
// A more robust solution might be needed depending on UX preferences.
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-slate-100 p-4">
|
||||
<LoginForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/pages/admin/species/create.tsx
Normal file
57
src/pages/admin/species/create.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { SpeciesForm } from '@/components/admin/SpeciesForm';
|
||||
import { speciesApi, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export default function CreateSpecies() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Create species mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Species) => speciesApi.create(data),
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Species "${data.scientific_name}" has been created successfully.`,
|
||||
});
|
||||
// navigate('/admin/species');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to create species: ${(error as Error).message}`,
|
||||
});
|
||||
// setIsSubmitting(false); // Will be handled by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: Species) => {
|
||||
setIsSubmitting(true);
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Add New Species</h1>
|
||||
<p className="text-muted-foreground">Create a new species record</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-6">
|
||||
<SpeciesForm
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
102
src/pages/admin/species/edit.tsx
Normal file
102
src/pages/admin/species/edit.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { SpeciesForm } from '@/components/admin/SpeciesForm';
|
||||
import { speciesApi, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
export default function EditSpecies() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch species data
|
||||
const { data: species, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'species', id],
|
||||
queryFn: () => id ? speciesApi.getById(id) : Promise.reject('Species ID is required'),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Update species mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Species) => id ? speciesApi.update(id, data) : Promise.reject('Species ID is required'),
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Species "${data.scientific_name}" has been updated successfully.`,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'species'] });
|
||||
// navigate('/admin/species'); // Removed this line
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: `Failed to update species: ${(error as Error).message}`,
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
console.log('[EditSpecies] updateMutation onSettled. Setting isSubmitting to false.');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: Species) => {
|
||||
console.log('[EditSpecies] handleSubmit called. Setting isSubmitting to true.');
|
||||
setIsSubmitting(true);
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex justify-center p-8">Loading species data...</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading species data: {(error as Error).message}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/admin/species')}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to Species List
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold">Edit Species</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Edit details for <span className="italic">{species?.scientific_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-6">
|
||||
{species && (
|
||||
<SpeciesForm
|
||||
initialData={species}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
278
src/pages/admin/species/list.tsx
Normal file
278
src/pages/admin/species/list.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Search, Pencil, Trash } from 'lucide-react';
|
||||
import { speciesApi, Species } from '@/services/adminApi';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export default function SpeciesList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState<{open: boolean, species?: Species}>({open: false});
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const limit = 10; // Items per page
|
||||
const { toast } = useToast();
|
||||
|
||||
// Data fetching with React Query
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'species', page, isSearching ? searchQuery : null],
|
||||
queryFn: async () => {
|
||||
if (isSearching && searchQuery) {
|
||||
const searchData = await speciesApi.search(searchQuery);
|
||||
return { data: searchData, count: searchData.length }; // Assuming search doesn't paginate server-side for this example
|
||||
}
|
||||
return speciesApi.getAll(page, limit);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => speciesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Species deleted',
|
||||
description: 'The species has been successfully deleted.',
|
||||
});
|
||||
queryClient.invalidateQueries({queryKey: ['admin', 'species']});
|
||||
setDeleteDialog({open: false});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to delete species. ${(err as Error).message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSearching(!!searchQuery);
|
||||
setPage(1); // Reset to first page on new search
|
||||
queryClient.invalidateQueries({queryKey: ['admin', 'species']}); // Refetch with search query
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setIsSearching(false);
|
||||
setPage(1); // Reset to first page
|
||||
queryClient.invalidateQueries({queryKey: ['admin', 'species']}); // Refetch without search query
|
||||
};
|
||||
|
||||
// Open delete dialog
|
||||
const confirmDelete = (species: Species) => {
|
||||
setDeleteDialog({ open: true, species });
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = () => {
|
||||
if (deleteDialog.species?.id) {
|
||||
deleteMutation.mutate(deleteDialog.species.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate total pages
|
||||
const totalPages = data?.count ? Math.ceil(data.count / limit) : 1;
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Species</h1>
|
||||
<p className="text-muted-foreground">Manage your species database</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/admin/species/create">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Species
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by scientific or common name..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{isSearching && (
|
||||
<Button variant="outline" onClick={clearSearch}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Species table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-8">Loading species...</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-500">
|
||||
Error loading species: {(error as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Scientific Name</TableHead>
|
||||
<TableHead>Common Name</TableHead>
|
||||
<TableHead>Family</TableHead>
|
||||
<TableHead>IUCN Status</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data && data.data.length > 0 ? (
|
||||
data.data.map((species) => (
|
||||
<TableRow key={species.id}>
|
||||
<TableCell className="font-medium">
|
||||
<span className="italic">{species.scientific_name}</span>
|
||||
</TableCell>
|
||||
<TableCell>{species.common_name}</TableCell>
|
||||
<TableCell>{species.family}</TableCell>
|
||||
<TableCell>
|
||||
{/* This would need to be fetched in a real implementation */}
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||
LC
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/admin/species/${species.id}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={() => confirmDelete(species)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
No species found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!isSearching && data?.count && data.count > 0 && (
|
||||
<Pagination className="mt-4">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
// isActive={page > 1} // isActive is not a prop of PaginationPrevious
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={pageNumber}>
|
||||
<PaginationLink
|
||||
isActive={page === pageNumber}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
// isActive={page < totalPages} // isActive is not a prop of PaginationNext
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Species</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete <span className="font-medium italic">{deleteDialog.species?.scientific_name}</span>?
|
||||
This action cannot be undone, and all related data will also be deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialog({ open: false })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,126 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react'; // Removed useEffect
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAllSpecies } from '@/lib/api';
|
||||
import { SearchForm } from '@/components/search-form';
|
||||
import { getAllSpecies, getSpeciesImages, getTotalTradeCount, Species } from '@/lib/api'; // Added Species
|
||||
// Removed unused: import { SearchForm } from '@/components/search-form';
|
||||
import { ResultsContainer } from '@/components/results-container';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, Globe } from 'lucide-react';
|
||||
// Removed Globe, Info, BarChart3
|
||||
import { Filter, ArrowRight, AlertTriangle, ImageIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
// Add Tabs imports
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
// Import the new CompareTradeTab component
|
||||
import { CompareTradeTab } from '@/components/compare-trade-tab';
|
||||
|
||||
// Define a local type for the SpeciesCard props that includes optional latest_assessment
|
||||
type SpeciesCardProps = {
|
||||
species: Species & { latest_assessment?: { status?: string } }; // Make latest_assessment and its status optional
|
||||
onClick: (id: string) => void;
|
||||
};
|
||||
|
||||
// Species Card Component with Image Support
|
||||
function SpeciesCard({ species, onClick }: SpeciesCardProps) { // Used local SpeciesCardProps type
|
||||
const { data: imageData, isLoading: isImageLoading } = useQuery({
|
||||
queryKey: ['speciesImage', species.scientific_name],
|
||||
queryFn: () => getSpeciesImages(species.scientific_name),
|
||||
enabled: !!species.scientific_name,
|
||||
});
|
||||
|
||||
// Generate a pseudo-random background color based on species name
|
||||
const getBackgroundColor = (name: string) => {
|
||||
const colors = [
|
||||
'bg-blue-100 dark:bg-blue-900/50',
|
||||
'bg-green-100 dark:bg-green-900/50',
|
||||
'bg-teal-100 dark:bg-teal-900/50',
|
||||
'bg-cyan-100 dark:bg-cyan-900/50',
|
||||
'bg-indigo-100 dark:bg-indigo-900/50',
|
||||
'bg-purple-100 dark:bg-purple-900/50'
|
||||
];
|
||||
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={species.id}
|
||||
className="group overflow-hidden transition-all hover:shadow-lg hover:border-blue-200 dark:hover:border-blue-800"
|
||||
onClick={() => onClick(species.id)}
|
||||
>
|
||||
{/* Removed inline style from CardTitle */}
|
||||
<div className={`relative h-72 ${getBackgroundColor(species.scientific_name)} overflow-hidden`}>
|
||||
{isImageLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-gray-900 dark:border-gray-200"></div>
|
||||
</div>
|
||||
) : imageData?.url ? (
|
||||
<>
|
||||
<img
|
||||
src={imageData.url}
|
||||
alt={species.scientific_name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/50"></div>
|
||||
{imageData?.attribution && (
|
||||
<div className="absolute bottom-0 right-0 bg-black/50 px-2 py-1 text-sm text-white max-w-[80%] truncate">
|
||||
{imageData.attribution}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/30"></div>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center opacity-50">
|
||||
<ImageIcon className="h-14 w-14" />
|
||||
<span className="text-base mt-2">No image</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute bottom-3 left-3">
|
||||
{species.latest_assessment?.status && (
|
||||
<Badge variant="outline" className="bg-white/80 text-sm font-medium px-3 py-1">
|
||||
{species.latest_assessment.status === 'CR' && <AlertTriangle className="mr-1 h-4 w-4 text-red-500" />}
|
||||
IUCN: {species.latest_assessment.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
{/* Removed inline style */}
|
||||
<CardTitle className="text-2xl md:text-3xl group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||
<span className="italic">{species.scientific_name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
{/* Removed inline style */}
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300">{species.primary_common_name}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between">
|
||||
{/* Removed inline style */}
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-lg font-medium text-gray-800 dark:text-gray-200">
|
||||
{species.family}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" className="p-0 h-9 w-9 rounded-full">
|
||||
<ArrowRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
// App state
|
||||
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string | null>(null);
|
||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||
|
||||
const { data: allSpecies, isLoading } = useQuery({
|
||||
const { data: allSpecies, isLoading: isLoadingSpecies } = useQuery({
|
||||
queryKey: ['allSpecies'],
|
||||
queryFn: getAllSpecies,
|
||||
queryFn: getAllSpecies
|
||||
});
|
||||
|
||||
// Fetch total trade count
|
||||
const { data: tradeCountData, isLoading: isLoadingTradeCount } = useQuery({
|
||||
queryKey: ['totalTradeCount'],
|
||||
queryFn: getTotalTradeCount
|
||||
});
|
||||
|
||||
// Create a filtered list of unique species by scientific name
|
||||
@ -32,110 +139,111 @@ export function HomePage() {
|
||||
|
||||
const handleSelectSpecies = (id: string) => {
|
||||
setSelectedSpeciesId(id);
|
||||
setShowSearch(false);
|
||||
};
|
||||
|
||||
const handleBackToSearch = () => {
|
||||
setSelectedSpeciesId(null);
|
||||
};
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
setShowSearch(!showSearch);
|
||||
if (selectedSpeciesId) {
|
||||
setSelectedSpeciesId(null);
|
||||
}
|
||||
// Combine species and trade counts for stats
|
||||
const keyFigures = useMemo(() => {
|
||||
return {
|
||||
totalSpecies: uniqueSpecies.length,
|
||||
totalTrades: tradeCountData?.count ?? 0, // Use nullish coalescing
|
||||
};
|
||||
}, [uniqueSpecies, tradeCountData]);
|
||||
|
||||
console.log('All species length:', allSpecies?.length);
|
||||
console.log('Unique species length:', uniqueSpecies?.length);
|
||||
if (selectedSpeciesId) {
|
||||
return <ResultsContainer speciesId={selectedSpeciesId} onBack={handleBackToSearch} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto my-8 space-y-8 px-4">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<h1 className="mb-4 text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-100">Arctic Species Tracker</h1>
|
||||
<p className="mb-4 text-base font-semibold text-blue-600 dark:text-blue-400">**Work in Progress!**</p>
|
||||
<div className="mb-6 text-base text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<p className="font-medium">A collaborative project between:</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="font-medium">School of Humanities and Social Sciences</div>
|
||||
<div className="space-y-1">
|
||||
<p>Thomas Barry</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Dean of School</p>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p>Magnus Smari Smarason</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">AI Project Manager</p>
|
||||
</div>
|
||||
<div className="mt-4 font-medium">University of Akureyri</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-8 text-lg text-gray-600 dark:text-gray-300">
|
||||
Track conservation status, CITES listings, and trade data for Arctic species
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gradient-to-r from-blue-900 to-blue-700 text-white">
|
||||
{/* Removed arctic-bg.jpg reference causing 404 */}
|
||||
{/* Reduced padding py-16 md:py-20 */}
|
||||
<div className="container relative mx-auto px-8 py-16 text-center md:py-20">
|
||||
{/* Reduced text size text-4xl md:text-5xl, mb-3 */}
|
||||
<h1 className="mb-3 text-4xl font-bold tracking-tight md:text-5xl">
|
||||
Arctic Species Tracker
|
||||
</h1>
|
||||
{/* Reduced text size text-lg, mb-8 */}
|
||||
<p className="mb-8 mx-auto max-w-2xl text-lg text-blue-100">
|
||||
Tracking conservation status, CITES listings, and trade data for Arctic species
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button
|
||||
onClick={handleToggleSearch}
|
||||
variant={showSearch ? "default" : "outline"}
|
||||
className="min-w-[160px] h-11"
|
||||
>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
Search Species
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowSearch(false)}
|
||||
variant={!showSearch && !selectedSpeciesId ? "default" : "outline"}
|
||||
className="min-w-[160px] h-11"
|
||||
>
|
||||
<Globe className="mr-2 h-5 w-5" />
|
||||
Browse All Species
|
||||
</Button>
|
||||
|
||||
{/* Key Stats - Increased margin-top mt-8, added flex container */}
|
||||
<div className="mt-8 flex justify-center space-x-6">
|
||||
{/* Species Count */}
|
||||
<div className="rounded-lg bg-white/10 backdrop-blur-md p-4 text-center">
|
||||
<div className="text-4xl font-bold">{keyFigures.totalSpecies}</div>
|
||||
<div className="text-lg text-blue-200">Total Species</div> {/* Reduced text size */}
|
||||
</div>
|
||||
{/* Trade Count */}
|
||||
<div className="rounded-lg bg-white/10 backdrop-blur-md p-4 text-center">
|
||||
<div className="text-4xl font-bold">
|
||||
{isLoadingTradeCount ? '...' : keyFigures.totalTrades.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-lg text-blue-200">Total Trades</div> {/* Reduced text size */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Results Section */}
|
||||
<div className="mt-12">
|
||||
{showSearch && !selectedSpeciesId ? (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center space-y-6">
|
||||
<SearchForm onSelectSpecies={handleSelectSpecies} />
|
||||
</div>
|
||||
) : selectedSpeciesId ? (
|
||||
<ResultsContainer speciesId={selectedSpeciesId} onBack={handleBackToSearch} />
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex justify-center items-center py-12">
|
||||
{/* Main Content Area with Tabs */}
|
||||
<div className="flex-grow bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="container mx-auto px-6">
|
||||
{/* Add Tabs component */}
|
||||
<Tabs defaultValue="browse" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6"> {/* Added mb-6 for spacing */}
|
||||
<TabsTrigger value="browse">Browse Species</TabsTrigger>
|
||||
<TabsTrigger value="compare">Compare Trade</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Browse Species Tab Content */}
|
||||
<TabsContent value="browse">
|
||||
{isLoadingSpecies ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="flex items-center space-x-4 text-gray-500">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<p>Loading species data...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : uniqueSpecies.length > 0 ? (
|
||||
uniqueSpecies.map(species => (
|
||||
<Card
|
||||
key={species.id}
|
||||
className="cursor-pointer transition-all hover:shadow-lg hover:border-blue-200 dark:hover:border-blue-800"
|
||||
onClick={() => handleSelectSpecies(species.id)}
|
||||
>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-lg">
|
||||
<span className="italic">{species.scientific_name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{species.primary_common_name}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-medium text-gray-800 dark:text-gray-200">
|
||||
{species.family}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
{/* Keep h2 and Filter button */}
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Browse Arctic Species</h2>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" /> Filter
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-10 md:gap-12 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{uniqueSpecies.length > 0 ? (
|
||||
uniqueSpecies.map(species => (
|
||||
<SpeciesCard
|
||||
key={species.id}
|
||||
species={species}
|
||||
onClick={handleSelectSpecies}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="col-span-full text-center text-gray-500 dark:text-gray-400 py-12">No species found</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Compare Trade Tab Content */}
|
||||
<TabsContent value="compare">
|
||||
{/* Render the CompareTradeTab component */}
|
||||
<CompareTradeTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
362
src/services/adminApi.ts
Normal file
362
src/services/adminApi.ts
Normal file
@ -0,0 +1,362 @@
|
||||
import { supabase } from '@/lib/supabase'; // Import the shared client
|
||||
|
||||
// Types
|
||||
export interface Species {
|
||||
id?: string;
|
||||
scientific_name: string;
|
||||
common_name: string;
|
||||
kingdom: string;
|
||||
phylum: string;
|
||||
class: string;
|
||||
order_name: string;
|
||||
family: string;
|
||||
genus: string;
|
||||
species_name: string;
|
||||
authority: string;
|
||||
sis_id?: number;
|
||||
inaturalist_id?: number;
|
||||
default_image_url?: string;
|
||||
description?: string;
|
||||
habitat_description?: string;
|
||||
population_trend?: string;
|
||||
population_size?: number | null; // Changed from string
|
||||
generation_length?: number | null; // Changed from string
|
||||
movement_patterns?: string;
|
||||
use_and_trade?: string;
|
||||
threats_overview?: string;
|
||||
conservation_overview?: string;
|
||||
}
|
||||
|
||||
export interface CitesListing {
|
||||
id?: string;
|
||||
species_id: string;
|
||||
appendix: string;
|
||||
listing_date: string; // ISO date string
|
||||
notes?: string;
|
||||
is_current: boolean;
|
||||
}
|
||||
|
||||
export interface IucnAssessment {
|
||||
id?: string;
|
||||
species_id: string;
|
||||
year_published: number;
|
||||
status: string;
|
||||
is_latest: boolean;
|
||||
possibly_extinct: boolean;
|
||||
possibly_extinct_in_wild: boolean;
|
||||
url?: string;
|
||||
assessment_id?: number;
|
||||
scope_code?: string;
|
||||
scope_description?: string;
|
||||
// Add the following fields:
|
||||
red_list_criteria?: string;
|
||||
year_assessed?: number | null;
|
||||
population_trend?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CommonName {
|
||||
id?: string;
|
||||
species_id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
is_main: boolean;
|
||||
}
|
||||
|
||||
// Species CRUD
|
||||
export const speciesApi = {
|
||||
// Get all species with pagination
|
||||
getAll: async (page = 1, limit = 10) => {
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('species')
|
||||
.select('*', { count: 'exact' })
|
||||
.range(start, end)
|
||||
.order('scientific_name');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { data, count };
|
||||
},
|
||||
|
||||
// Get a single species by ID
|
||||
getById: async (id: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('species')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create a new species
|
||||
create: async (species: Species) => {
|
||||
const { data, error } = await supabase
|
||||
.from('species')
|
||||
.insert(species)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Update an existing species
|
||||
update: async (id: string, species: Partial<Species>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('species')
|
||||
.update(species)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Delete a species
|
||||
delete: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('species')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// Search species
|
||||
search: async (query: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('species')
|
||||
.select('*')
|
||||
.or(`scientific_name.ilike.%${query}%,common_name.ilike.%${query}%`)
|
||||
.order('scientific_name');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
// CITES Listings CRUD
|
||||
export const citesListingsApi = {
|
||||
// Get all listings for a species
|
||||
getBySpeciesId: async (speciesId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('cites_listings')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('listing_date', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create a new listing
|
||||
create: async (listing: CitesListing) => {
|
||||
const { data, error } = await supabase
|
||||
.from('cites_listings')
|
||||
.insert(listing)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Update a listing
|
||||
update: async (id: string, listing: Partial<CitesListing>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('cites_listings')
|
||||
.update(listing)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Delete a listing
|
||||
delete: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('cites_listings')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Similar CRUD operations for IUCN assessments, common names, etc.
|
||||
export const iucnAssessmentsApi = {
|
||||
getBySpeciesId: async (speciesId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('iucn_assessments')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('year_published', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (assessment: IucnAssessment) => {
|
||||
const { data, error } = await supabase
|
||||
.from('iucn_assessments')
|
||||
.insert(assessment)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
update: async (id: string, assessment: Partial<IucnAssessment>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('iucn_assessments')
|
||||
.update(assessment)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('iucn_assessments')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// For filtering and searching
|
||||
search: async (query: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('iucn_assessments')
|
||||
.select('*, species:species_id(*)')
|
||||
.or(`status.ilike.%${query}%,species.scientific_name.ilike.%${query}%`)
|
||||
.order('year_published', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
export const commonNamesApi = {
|
||||
getBySpeciesId: async (speciesId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('common_names')
|
||||
.select('*')
|
||||
.eq('species_id', speciesId)
|
||||
.order('is_main', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get all common names
|
||||
getAll: async (page = 1, limit = 10) => {
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('common_names')
|
||||
.select('*, species:species_id(scientific_name, common_name)', { count: 'exact' })
|
||||
.range(start, end)
|
||||
.order('is_main', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { data, count };
|
||||
},
|
||||
|
||||
// Get a single common name by ID
|
||||
getById: async (id: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('common_names')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create a new common name
|
||||
create: async (commonName: CommonName) => {
|
||||
const { data, error } = await supabase
|
||||
.from('common_names')
|
||||
.insert(commonName)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Update an existing common name
|
||||
update: async (id: string, commonName: Partial<CommonName>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('common_names')
|
||||
.update(commonName)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Delete a common name
|
||||
delete: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('common_names')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// Search common names
|
||||
search: async (query: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('common_names')
|
||||
.select('*, species:species_id(scientific_name, common_name)')
|
||||
.or(`name.ilike.%${query}%,language.ilike.%${query}%,species.scientific_name.ilike.%${query}%`)
|
||||
.order('is_main', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
};
|
||||
@ -10,67 +10,82 @@ module.exports = {
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
'2xl': '1400px'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
'accordion-down': {
|
||||
from: {
|
||||
height: 0
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)'
|
||||
}
|
||||
},
|
||||
'accordion-up': {
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)'
|
||||
},
|
||||
to: {
|
||||
height: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
@ -4,7 +4,7 @@ import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/arctic-species-2025-frontend/',
|
||||
base: '/Arctic_portal_temp/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user