diff --git a/App.tsx b/App.tsx index 8fbc938..6f0dd33 100644 --- a/App.tsx +++ b/App.tsx @@ -412,8 +412,10 @@ const App: React.FC = () => { if (data.species) { const localSpecies = getSpecies(); const localSpeciesMap = new Map(localSpecies.map(s => [s.id, s])); + const serverSpeciesIds = new Set(data.species.map((s: any) => s.id as string)); + const localOnlySpecies = localSpecies.filter(s => !serverSpeciesIds.has(s.id)); // Server is authoritative — only merge server records (plus cached imageUrls). - // Do NOT push local-only items back; items are pushed at creation/edit time. + // Local-only items are pushed at creation/edit time; this is a safety fallback. const mergedSpecies = data.species.map((s: any) => ({ ...s, imageUrl: s.imageUrl || localSpeciesMap.get(s.id)?.imageUrl || undefined, @@ -442,6 +444,8 @@ const App: React.FC = () => { if (data.individuals) { const localInds = getIndividuals(); const localIndMap = new Map(localInds.map(i => [i.id, i])); + const serverIndIds = new Set(data.individuals.map((i: any) => i.id as string)); + const localOnlyInds = localInds.filter(i => !serverIndIds.has(i.id)); // Server is authoritative — only merge server records (plus cached imageUrls). const mergedInds = data.individuals.map((i: any) => ({ ...i, diff --git a/components/ConfirmModal.tsx b/components/ConfirmModal.tsx new file mode 100644 index 0000000..2fedb3a --- /dev/null +++ b/components/ConfirmModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'; + +export interface ConfirmModalProps { + isOpen: boolean; + title: string; + message: React.ReactNode; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; + isLoading?: boolean; + /** 'danger' = red button (default). 'warning' = amber button. */ + variant?: 'danger' | 'warning'; +} + +const ConfirmModal: React.FC = ({ + isOpen, + title, + message, + confirmLabel = 'Delete', + cancelLabel = 'Cancel', + onConfirm, + onCancel, + isLoading = false, + variant = 'danger', +}) => { + if (!isOpen) return null; + + const btnCls = variant === 'danger' + ? 'bg-red-600 hover:bg-red-700 shadow-red-100' + : 'bg-amber-500 hover:bg-amber-600 shadow-amber-100'; + + const iconBg = variant === 'danger' + ? 'bg-red-100 text-red-600' + : 'bg-amber-100 text-amber-600'; + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Close button */} + + +
+
+ +
+
+

{title}

+
{message}
+
+
+ +
+ + +
+
+
+ ); +}; + +export default ConfirmModal; diff --git a/package-lock.json b/package-lock.json index 7bf413a..fe7f9f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3587,9 +3587,9 @@ } }, "node_modules/react-router": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", - "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3609,12 +3609,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", - "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", "license": "MIT", "dependencies": { - "react-router": "7.13.0" + "react-router": "7.16.0" }, "engines": { "node": ">=20.0.0" diff --git a/pages/IndividualDetail.tsx b/pages/IndividualDetail.tsx index f6ee3e5..ba2e9b3 100644 --- a/pages/IndividualDetail.tsx +++ b/pages/IndividualDetail.tsx @@ -7,6 +7,7 @@ import { Individual, Species, WeightRecord, HealthRecord, GrowthRecord, Breeding import { ArrowLeft, Scale, Activity, Syringe, Calendar, Plus, Stethoscope, Sprout, Camera, MapPin, Navigation, X, ChevronLeft, ChevronRight, Maximize2, Briefcase, Archive, Edit, Baby, Heart, ArrowRightLeft, ExternalLink, Fingerprint, Download, FileCode, Box, Trash2, Loader2, Upload, ImageIcon, Info } from 'lucide-react'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'; import { LanguageContext } from '../App'; +import ConfirmModal from '../components/ConfirmModal'; declare const L: any; @@ -39,6 +40,7 @@ const IndividualDetail: React.FC = () => { const [showWeightModal, setShowWeightModal] = useState(false); const [showHealthModal, setShowHealthModal] = useState(false); const [galleryIndex, setGalleryIndex] = useState(-1); + const [showDnaDeleteConfirm, setShowDnaDeleteConfirm] = useState(false); // Form Media State const [pendingLogImage, setPendingLogImage] = useState(''); @@ -118,7 +120,7 @@ const IndividualDetail: React.FC = () => { const ACCURACY_TARGET = 20; let settled = false; - const handleMapClick = (e: L.LeafletMouseEvent) => { + const handleMapClick = (e: any) => { const { lat, lng } = e.latlng; setPendingLatLng({ lat, lng }); locationMarkerRef.current?.setLatLng([lat, lng]); @@ -722,7 +724,7 @@ const IndividualDetail: React.FC = () => { Sequence Detected: {individual.dnaFileType || 'FASTA'}
- +
@@ -947,6 +949,17 @@ const IndividualDetail: React.FC = () => { ); })()} + { + if (individual) setIndividual({ ...individual, dnaSequence: undefined }); + setShowDnaDeleteConfirm(false); + }} + onCancel={() => setShowDnaDeleteConfirm(false)} + />
); }; diff --git a/pages/IndividualManager.tsx b/pages/IndividualManager.tsx index 8115d65..51c5f37 100644 --- a/pages/IndividualManager.tsx +++ b/pages/IndividualManager.tsx @@ -4,8 +4,9 @@ import { getSpecies, getIndividuals, saveIndividuals, generatePattern, saveSpeci import { fetchSpeciesData, generateSpeciesImage, fetchWikimediaImage } from '../services/geminiService'; import { compressImageFileDual, compressDataUrlDual } from '../services/imageUtils'; import { Species, Individual, Sex, SpeciesType, Organization, Enclosure, Project, PlantClassification } from '../types'; -import { Plus, Search, Dna, PawPrint, Pencil, X as XIcon, MapPin, LayoutGrid, List, Box, ChevronDown, Save, Camera, ImageIcon, Info, Crosshair, Map as MapIcon2, Sparkles, Loader2, Upload, CheckCircle2, AlertTriangle, AlertCircle, FileSpreadsheet, Check, Trash2, Maximize2, Minimize2, Tag } from 'lucide-react'; +import { Plus, Search, Dna, PawPrint, Pencil, X as XIcon, MapPin, LayoutGrid, List, Box, ChevronDown, Save, Camera, ImageIcon, Info, Crosshair, Map as MapIcon2, Sparkles, Loader2, Upload, CheckCircle2, AlertTriangle, AlertCircle, FileSpreadsheet, Check, Trash2, Maximize2, Minimize2, Tag, HelpCircle, Sprout } from 'lucide-react'; import { LanguageContext } from '../App'; +import ConfirmModal from '../components/ConfirmModal'; declare const L: any; @@ -41,6 +42,17 @@ const IndividualManager: React.FC = ({ currentProjectId, const [isSpeciesDropdownOpen, setIsSpeciesDropdownOpen] = useState(false); const [addLocation, setAddLocation] = useState(false); + // Unknown Species picker + const [showUnknownPicker, setShowUnknownPicker] = useState(false); + const [isCreatingUnknown, setIsCreatingUnknown] = useState(false); + + // Presence-only mode (species generally present, no specific individual tracked) + const [presenceOnly, setPresenceOnly] = useState(false); + + // Delete confirmation modal + const [bulkDeletePending, setBulkDeletePending] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + // Quick Add Species State const [showQuickSpeciesModal, setShowQuickSpeciesModal] = useState(false); const [isQuickSpeciesLoading, setIsQuickSpeciesLoading] = useState(false); @@ -136,7 +148,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, scientificname: 'scientificName', sex: 'sex', birthdate: 'birthDate', dob: 'birthDate', dateofbirth: 'birthDate', dateplanted: 'birthDate', date_planted: 'birthDate', planted: 'birthDate', - weightkg: 'weightKg', 'weightkg': 'weightKg', weight: 'weightKg', + weightkg: 'weightKg', weight: 'weightKg', notes: 'notes', source: 'source', sourcedetails: 'sourceDetails', latitude: 'latitude', lat: 'latitude', @@ -247,9 +259,10 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, try { // ── Step 1: Auto-create missing species via AI ───────────────────────── - const newSpeciesNames = [...new Set( - importRows.filter(r => r._isNewSpecies && !r._speciesMatch).map(r => r._speciesLabel) - )]; + const _rawLabels: string[] = importRows + .filter(r => r._isNewSpecies && !r._speciesMatch) + .map(r => String(r._speciesLabel)); + const newSpeciesNames: string[] = [...new Set(_rawLabels)]; for (const speciesName of newSpeciesNames) { setImportStatus(`Creating species: ${speciesName}…`); @@ -402,7 +415,16 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, const projs = getProjects(); console.log(`[IndividualManager] useEffect: syncVersion=${syncVersion}, currentProjectId="${currentProjectId}", cache=${individuals.length} individuals, ${projs.length} projects`); setAllIndividuals(individuals); - setAllSpecies(getSpecies()); + // Repair any unknown-species records that were saved with a broken upload URL + // (pre-fix they ended up as /uploads/xxx.svgxml instead of the static path) + const loadedSpecies = getSpecies().map(s => { + if (s.isUnknown) { + const correctUrl = s.type === 'Animal' ? '/unknown-fauna.svg' : '/unknown-flora.svg'; + if (s.imageUrl !== correctUrl) return { ...s, imageUrl: correctUrl }; + } + return s; + }); + setAllSpecies(loadedSpecies); setAllProjects(projs); setAllEnclosures(getEnclosures()); const currentOrg = getOrg(); @@ -444,6 +466,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, const sp = allSpecies.find(s => s.id === indToEdit.speciesId); setSpeciesSearchQuery(sp?.commonName || ''); setAddLocation(!!(indToEdit.latitude || indToEdit.longitude)); + setPresenceOnly(!!indToEdit.isPresenceOnly); setShowForm(true); window.history.replaceState({}, document.title); } @@ -626,6 +649,8 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, }); setSpeciesSearchQuery(''); setAddLocation(false); + setShowUnknownPicker(false); + setPresenceOnly(false); setShowForm(true); }; @@ -706,6 +731,53 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, } }; + /** Find or create the shared "Unknown Fauna/Flora Species" for this project */ + const getOrCreateUnknownSpecies = async (type: SpeciesType): Promise => { + const targetProjectId = isAll ? formData.projectId : currentProjectId; + if (!targetProjectId) throw new Error('Select a project first.'); + + // Re-use an existing unknown species of this type in this project + const existing = allSpecies.find(s => s.isUnknown && s.type === type && s.projectId === targetProjectId); + if (existing) return existing; + + // Static placeholder images shipped with the app (avoids the base64 upload pipeline) + const imageUrl = type === 'Animal' ? '/unknown-fauna.svg' : '/unknown-flora.svg'; + + const newSpecies: Species = { + id: `unknown-${type.toLowerCase()}-${targetProjectId}`, + projectId: targetProjectId, + commonName: type === 'Animal' ? 'Unknown Fauna Species' : 'Unknown Flora Species', + scientificName: 'Species incognita', + type, + conservationStatus: 'Unknown', + sexualMaturityAgeYears: 0, + lifeExpectancyYears: 0, + averageAdultWeightKg: 0, + imageUrl, + isUnknown: true, + }; + + const updated = [...allSpecies, newSpecies]; + await saveSpecies(updated); + setAllSpecies(updated); + return newSpecies; + }; + + const handleSelectUnknown = async (type: SpeciesType) => { + setIsCreatingUnknown(true); + try { + const sp = await getOrCreateUnknownSpecies(type); + setFormData(prev => ({ ...prev, speciesId: sp.id })); + setSpeciesSearchQuery(sp.commonName); + setIsSpeciesDropdownOpen(false); + setShowUnknownPicker(false); + } catch (e: any) { + alert(e.message); + } finally { + setIsCreatingUnknown(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.speciesId || !formData.studbookId) return; @@ -714,8 +786,12 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, if (isPlant && !finalName) { finalName = formData.studbookId || `plant-${Date.now()}`; } + if (presenceOnly && !finalName) { + // Auto-name presence records based on species so they're identifiable + finalName = `${selectedSpecies?.commonName || 'Species'} – Presence Record`; + } - if (!finalName && !isPlant) { + if (!finalName && !isPlant && !presenceOnly) { alert("Name is required for Fauna records."); return; } @@ -732,7 +808,8 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, projectId: targetProjectId, weightKg: Number(formData.weightKg || 0), latitude: addLocation ? formData.latitude : undefined, - longitude: addLocation ? formData.longitude : undefined + longitude: addLocation ? formData.longitude : undefined, + isPresenceOnly: presenceOnly || undefined, }; const updated = editingId ? allIndividuals.map(i => i.id === editingId ? entry : i) : [...allIndividuals, entry]; setAllIndividuals(updated); @@ -765,15 +842,22 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, const handleSelectAll = () => setSelectedIds(selectedIds.size === filtered.length ? new Set() : new Set(filtered.map(i => i.id))); - const handleBulkDelete = async () => { - if (!confirm(`Permanently delete ${selectedIds.size} individual(s)? This cannot be undone.`)) return; - // Delete each record individually so the server receives a proper DELETE request. - // saveIndividuals(filtered) only upserts the remaining records — it never removes. - for (const id of selectedIds) { - await deleteIndividual(id); + const handleBulkDelete = () => setBulkDeletePending(true); + + const handleConfirmBulkDelete = async () => { + setIsDeleting(true); + try { + // Delete each record individually so the server receives a proper DELETE request. + // saveIndividuals(filtered) only upserts the remaining records — it never removes. + for (const id of selectedIds) { + await deleteIndividual(id); + } + setAllIndividuals(getIndividuals()); + setSelectedIds(new Set()); + } finally { + setIsDeleting(false); + setBulkDeletePending(false); } - setAllIndividuals(getIndividuals()); - setSelectedIds(new Set()); }; const handleBulkMarkDeceased = async () => { @@ -922,7 +1006,10 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, {displayName} - {!isPlantInd &&
{ind.sex}
} + {ind.isPresenceOnly + ?
Presence
+ : (!isPlantInd &&
{ind.sex}
) + } {ind.isDeceased &&
Deceased
}
@@ -943,7 +1030,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, )}
{ind.studbookId} - +
@@ -988,6 +1075,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, ) : ( {ind.name} )} + {ind.isPresenceOnly && Presence} {ind.isDeceased && Deceased} {isPlantInd ? '' : sp?.commonName} @@ -995,7 +1083,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,,
- +
@@ -1078,7 +1166,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, View Full Profile → - @@ -1328,7 +1416,7 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,,
{editingId ? : }
-

{editingId ? t('updateIndividual') : t('registerIndividual')}

+

{editingId ? t('updateIndividual') : 'Register Individual or Presence'}

@@ -1358,6 +1446,36 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, + + {/* Not yet identified */} + {!formData.speciesId && ( +
+ {!showUnknownPicker ? ( + + ) : ( +
+ Register as: + + + +
+ )} +
+ )}
setFormData({...formData, studbookId: e.target.value})} required />
@@ -1406,18 +1524,42 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,,
-
-

Physical Mapping

-
-
@@ -494,8 +571,9 @@ const SuperAdmin: React.FC = () => {
-
@@ -523,8 +601,9 @@ const SuperAdmin: React.FC = () => {
{settings.appLogoUrl ? : }