From d1506c5ad85b2f234748407b6ea99e6a987fb512 Mon Sep 17 00:00:00 2001 From: teruselearning Date: Wed, 10 Jun 2026 19:32:11 +0700 Subject: [PATCH] feat: native status badges, species modal, description field, presence records, i18n - Native status: replace Local/National text labels with a single Native/Non-Native/Invasive colour pill on species cards (SpeciesManager), individual cards (IndividualManager grid + list), IndividualDetail species panels, and SpeciesModal. "Introduced" is relabelled "Non-Native" for clarity. - Edit form: new Native Status section in SpeciesManager edit form with four pill-toggle buttons (Unknown / Native / Non-Native / Invasive) so staff can override the AI value. - AI prompt: backend /api/ai/species-data now explicitly asks Gemini to determine whether the species is Native, Introduced, or Invasive at the organisation's specific location (nativeStatusLocal) and at national level (nativeStatusCountry). - Species detail modal (SpeciesModal): new reusable component that renders species hero image, conservation badge, description, stats grid, native status pill, and Wikipedia / IUCN links as an overlay. Opened from the Info icon and Species Detail button on IndividualDetail. - Description field: added to Species type, SpeciesManager edit form, species cards, AI prompt, DB migration, and sync mapping. - Presence-only records: 3-way radio on IndividualManager physical mapping section (No location / Generally present / Pin location); teal Presence badge on grid cards. - Unknown species: each unidentified individual now creates its own unique species record with a working name and identifying features form; static SVG placeholders replace base64 upload pipeline. - IndividualDetail i18n: all hardcoded English labels replaced with t() calls; 36 new translation keys seeded into DB across all 7 supported languages. - tsconfig: added exclude array to prevent stale src/ directory from breaking tsc. - react-router bumped 7.13 to 7.16 to resolve 5 high-severity CVEs. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/index.ts | 10 +- backend/src/seed-languages.json | 21 ++-- components/SpeciesModal.tsx | 158 +++++++++++++++++++++++++ pages/IndividualDetail.tsx | 204 +++++++++++++++++++++----------- pages/IndividualManager.tsx | 179 ++++++++++++++++++---------- pages/SpeciesManager.tsx | 79 ++++++++++++- services/i18n.ts | 59 +++++++-- services/syncService.ts | 10 +- types.ts | 4 +- 9 files changed, 569 insertions(+), 155 deletions(-) create mode 100644 components/SpeciesModal.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 49e0eba..41b339d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -122,13 +122,15 @@ const runMigrations = async (db: mysql.Pool) => { await db.execute(`CREATE TABLE IF NOT EXISTS organizations (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), location VARCHAR(255), latitude DOUBLE, longitude DOUBLE, founded_year INT, description LONGTEXT, focus VARCHAR(255), is_org_public TINYINT(1) DEFAULT 0, is_species_public TINYINT(1) DEFAULT 0, obscure_location TINYINT(1) DEFAULT 1, hide_name TINYINT(1) DEFAULT 0, allow_breeding_requests TINYINT(1) DEFAULT 0, breeding_request_contact_id VARCHAR(255), show_native_status TINYINT(1) DEFAULT 1, dashboard_block JSON, enable_mfa TINYINT(1) DEFAULT 0, enable_enclosures TINYINT(1) DEFAULT 0, ai_usage_limit INT DEFAULT 100, ai_usage_count INT DEFAULT 0, ai_usage_last_reset VARCHAR(50), is_deleted TINYINT(1) DEFAULT 0)`); await db.execute(`CREATE TABLE IF NOT EXISTS users (id VARCHAR(255) PRIMARY KEY, org_id VARCHAR(255), name VARCHAR(255), email VARCHAR(255) UNIQUE, role VARCHAR(50), status VARCHAR(50), password VARCHAR(255), avatar_url LONGTEXT, allowed_project_ids JSON, preferred_language VARCHAR(10) DEFAULT 'en-GB', reset_code VARCHAR(10), reset_expires BIGINT)`); await db.execute(`CREATE TABLE IF NOT EXISTS projects (id VARCHAR(255) PRIMARY KEY, org_id VARCHAR(255), name VARCHAR(255) NOT NULL, description LONGTEXT)`); - await db.execute(`CREATE TABLE IF NOT EXISTS species (id VARCHAR(255) PRIMARY KEY, project_id VARCHAR(255), common_name VARCHAR(255) NOT NULL, scientific_name VARCHAR(255) NOT NULL, type VARCHAR(50) NOT NULL, plant_classification VARCHAR(50), conservation_status VARCHAR(255), sexual_maturity_age_years DOUBLE, average_adult_weight_kg DOUBLE, life_expectancy_years DOUBLE, breeding_season_start INT, breeding_season_end INT, image_url LONGTEXT, native_status_country TEXT, native_status_local TEXT)`); + await db.execute(`CREATE TABLE IF NOT EXISTS species (id VARCHAR(255) PRIMARY KEY, project_id VARCHAR(255), common_name VARCHAR(255) NOT NULL, scientific_name VARCHAR(255) NOT NULL, type VARCHAR(50) NOT NULL, plant_classification VARCHAR(50), conservation_status VARCHAR(255), sexual_maturity_age_years DOUBLE, average_adult_weight_kg DOUBLE, life_expectancy_years DOUBLE, breeding_season_start INT, breeding_season_end INT, image_url LONGTEXT, native_status_country TEXT, native_status_local TEXT, description LONGTEXT)`); await db.execute(`CREATE TABLE IF NOT EXISTS individuals (id VARCHAR(255) PRIMARY KEY, project_id VARCHAR(255), species_id VARCHAR(255), enclosure_id VARCHAR(255), studbook_id VARCHAR(255), name VARCHAR(255) NOT NULL, sex VARCHAR(20) NOT NULL, birth_date VARCHAR(50), weight_kg DOUBLE, sire_id VARCHAR(255), dam_id VARCHAR(255), image_url LONGTEXT, thumbnail_url LONGTEXT, dna_sequence LONGTEXT, notes VARCHAR(2000), source VARCHAR(255), source_details VARCHAR(255), latitude DOUBLE, longitude DOUBLE, is_deceased TINYINT(1) DEFAULT 0, death_date VARCHAR(50), loan_status VARCHAR(50), transferred_to_org_id VARCHAR(255), transfer_date VARCHAR(50), transfer_note LONGTEXT, weight_history JSON, growth_history JSON, health_history JSON)`); // Migration: add thumbnail_url to existing installs try { await db.execute(`ALTER TABLE individuals ADD COLUMN IF NOT EXISTS thumbnail_url LONGTEXT`); } catch (_) {} // Migration: widen native_status columns from VARCHAR(50) to TEXT for existing installs try { await db.execute(`ALTER TABLE species MODIFY COLUMN native_status_country TEXT`); } catch (_) {} try { await db.execute(`ALTER TABLE species MODIFY COLUMN native_status_local TEXT`); } catch (_) {} + // Migration: add description column to existing species tables + try { await db.execute(`ALTER TABLE species ADD COLUMN IF NOT EXISTS description LONGTEXT`); } catch (_) {} // Migration: orgs that still have the old hard-coded default limit of 100 get reset to 0 (unlimited). // 0 means no cap (superadmin / owner org behaviour). Also clear any stale monthly counter so the // org isn't stuck in a "limit reached" state caused by the low default. @@ -281,7 +283,7 @@ app.post('/api/ai/species-data', authenticate, async (req: any, res: any) => { const ai = new GoogleGenAI({ apiKey: await getEffectiveApiKey(req.user.orgId) }); const response = await ai.models.generateContent({ model: TEXT_MODEL, - contents: `Provide biological data for "${commonName}" (Kingdom: ${type === 'Animal' ? 'Fauna' : 'Flora'}). Org location: ${locationContext}. Return ONLY JSON.`, + contents: `Provide comprehensive biological data for "${commonName}" (Kingdom: ${type === 'Animal' ? 'Fauna' : 'Flora'}).${locationContext ? ` The organisation managing this species is located in: ${locationContext}. For nativeStatusLocal set whether this species is Native, Introduced (non-native), or Invasive specifically in that locality. For nativeStatusCountry set the status at the broader national or regional level.` : ''} For the description field write 2-3 informative sentences covering: general appearance/characteristics, reproductive behaviour, and native geographic distribution. Return ONLY JSON.`, config: { responseMimeType: "application/json", responseSchema: speciesSchema, @@ -656,7 +658,7 @@ app.get('/api/sync', authenticate, async (req: any, res: any) => { projectsRows = pj; const [u]: any = await db.execute(`SELECT id, org_id, name, email, role, status, avatar_url, allowed_project_ids FROM users`); usersRows = u; - const [s]: any = await db.execute(`SELECT id, project_id, common_name, scientific_name, type, plant_classification, conservation_status, sexual_maturity_age_years, average_adult_weight_kg, life_expectancy_years, breeding_season_start, breeding_season_end, image_url, native_status_country, native_status_local FROM species`); + const [s]: any = await db.execute(`SELECT id, project_id, common_name, scientific_name, type, plant_classification, conservation_status, sexual_maturity_age_years, average_adult_weight_kg, life_expectancy_years, breeding_season_start, breeding_season_end, image_url, native_status_country, native_status_local, description FROM species`); speciesRows = s; const [i]: any = await db.execute(`SELECT id, project_id, species_id, enclosure_id, studbook_id, name, sex, birth_date, weight_kg, sire_id, dam_id, thumbnail_url, dna_sequence, notes, source, source_details, latitude, longitude, is_deceased, death_date, loan_status, transferred_to_org_id, transfer_date, transfer_note, weight_history, growth_history, health_history FROM individuals`); individualsRows = i; @@ -671,7 +673,7 @@ app.get('/api/sync', authenticate, async (req: any, res: any) => { projectsRows = pj; const [u]: any = await db.execute(`SELECT id, org_id, name, email, role, status, avatar_url, allowed_project_ids FROM users WHERE org_id = ?`, [orgId]); usersRows = u; - const [s]: any = await db.execute(`SELECT s.id, s.project_id, s.common_name, s.scientific_name, s.type, s.plant_classification, s.conservation_status, s.sexual_maturity_age_years, s.average_adult_weight_kg, s.life_expectancy_years, s.breeding_season_start, s.breeding_season_end, s.image_url, s.native_status_country, s.native_status_local FROM species s JOIN projects p ON s.project_id = p.id WHERE p.org_id = ?`, [orgId]); + const [s]: any = await db.execute(`SELECT s.id, s.project_id, s.common_name, s.scientific_name, s.type, s.plant_classification, s.conservation_status, s.sexual_maturity_age_years, s.average_adult_weight_kg, s.life_expectancy_years, s.breeding_season_start, s.breeding_season_end, s.image_url, s.native_status_country, s.native_status_local, s.description FROM species s JOIN projects p ON s.project_id = p.id WHERE p.org_id = ?`, [orgId]); speciesRows = s; const [i]: any = await db.execute(`SELECT i.id, i.project_id, i.species_id, i.enclosure_id, i.studbook_id, i.name, i.sex, i.birth_date, i.weight_kg, i.sire_id, i.dam_id, i.thumbnail_url, i.dna_sequence, i.notes, i.source, i.source_details, i.latitude, i.longitude, i.is_deceased, i.death_date, i.loan_status, i.transferred_to_org_id, i.transfer_date, i.transfer_note, i.weight_history, i.growth_history, i.health_history FROM individuals i JOIN projects p ON i.project_id = p.id WHERE p.org_id = ?`, [orgId]); individualsRows = i; diff --git a/backend/src/seed-languages.json b/backend/src/seed-languages.json index 7378317..452c05f 100644 --- a/backend/src/seed-languages.json +++ b/backend/src/seed-languages.json @@ -21,7 +21,8 @@ "dashboardReady": "Your dashboard is ready", "dashboardReadyDesc": "Metrics, charts, and breeding insights will all appear here once you've added some species and individuals to your collection.", "addSpecies": "Add a Species", "addIndividual": "Add an Individual", "fullName": "Full Name", "preferredLanguage": "Preferred Language", "emailAddress": "Email Address", "superAdminOnly": "(Super Admin Only)", "projectAccess": "Project Access", "globalAccessAll": "Global (All Present & Future)", "restrictedSpecific": "Restricted (Select Specific)", "globalAccessInfo": "This user will have access to all projects by default and can switch between them using the project navigator.", "sendInvitation": "Send Invitation", "cancelInvitation": "Cancel Invitation?", "removeTeamMember": "Remove Team Member?", "cancelInviteConfirm": "Are you sure you want to cancel the invitation for {{name}}? They haven't joined the organisation yet.", "removeMemberConfirm": "Are you sure you want to remove {{name}}? They will lose all access to organisation data and projects.", "revokeInvitation": "Revoke Invitation", "removeAccess": "Remove Access", "inviteSent": "Invitation sent successfully!", "inviteFailed": "Failed to send invitation.", "inviteCancelled": "Invitation cancelled.", "memberRemoved": "User removed.", "actionFailed": "Action failed.", "acceptInvitation": "Accept Invitation", "invitedToJoin": "You've been invited to join", "setPassword": "Set Password", "minimumCharsHint": "Minimum 8 characters", "repeatPasswordHint": "Repeat password", "activating": "Activating...", "activateAccount": "Activate Account", "accountActivated": "Account activated! Redirecting...", "noTokenFound": "No invitation token found in the link.", "invalidInvite": "Invalid or expired invitation.", "passwordMismatch": "Passwords do not match.", "passwordTooShort": "Password must be at least 8 characters.", "activationFailed": "Failed to activate account.", - "enclosures": "Enclosures", "enclosure": "Enclosure", "areas": "Areas", "area": "Area", "enclosuresDescription": "Physical management of collections by site location.", "enableEnclosures": "Enable Enclosures", "enableAreas": "Enable Areas", "enableEnclosuresModuleDesc": "Enable advanced mapping and species grouping by physical location.", "orgWideView": "Organisation-Wide View", "networkDescription": "Discover other organisations and establish breeding partnerships.", "myPartners": "My Partners", "plantMapDescription": "Spatial distribution and precise physical tracking of your botanical collection.", "allManagedAreas": "All Managed Areas", "projectScope": "Project Scope", "hideLabels": "Hide Labels", "showLabels": "Show Labels", "noProjectsFound": "No Projects Found" + "enclosures": "Enclosures", "enclosure": "Enclosure", "areas": "Areas", "area": "Area", "enclosuresDescription": "Physical management of collections by site location.", "enableEnclosures": "Enable Enclosures", "enableAreas": "Enable Areas", "enableEnclosuresModuleDesc": "Enable advanced mapping and species grouping by physical location.", "orgWideView": "Organisation-Wide View", "networkDescription": "Discover other organisations and establish breeding partnerships.", "myPartners": "My Partners", "plantMapDescription": "Spatial distribution and precise physical tracking of your botanical collection.", "allManagedAreas": "All Managed Areas", "projectScope": "Project Scope", "hideLabels": "Hide Labels", "showLabels": "Show Labels", "noProjectsFound": "No Projects Found", + "history": "History", "genetics": "Genetics", "editProfile": "Edit Profile", "sex": "Sex", "birthDate": "Birth Date", "planted": "Planted", "deathDate": "Death Date", "removed": "Removed", "acquisitionSource": "Acquisition Source", "sourceDetails": "Source Details", "loanStatus": "Loan Status", "transferred": "Transferred", "transferNote": "Transfer Note", "lifeExpectancyShort": "Life Expectancy", "sexualMaturityShort": "Sexual Maturity", "avgAdultWeight": "Avg Adult Weight", "avgAdultHeight": "Avg Adult Height", "breedingSeason": "Breeding Season", "nativeStatus": "Native Status", "speciesInfo": "Species Info", "viewInSpeciesRegistry": "View in Species Registry", "location": "Location", "noCoordinatesAssigned": "No coordinates assigned", "setLocation": "Set Location", "weightTrend": "Weight Trend", "growthTrend": "Growth Trend", "logWeight": "Log Weight", "logHeight": "Log Height", "addObservation": "Add Observation", "parentage": "Parentage", "sire": "Sire", "dam": "Dam", "performedBy": "Performed By", "speciesDetail": "Species Detail", "weightHistory": "Weight History", "healthHistory": "Health History" } }, { @@ -46,7 +47,8 @@ "dashboardReady": "Your dashboard is ready", "dashboardReadyDesc": "Metrics, charts, and breeding insights will all appear here once you've added some species and individuals to your collection.", "addSpecies": "Add a Species", "addIndividual": "Add an Individual", "fullName": "Full Name", "preferredLanguage": "Preferred Language", "emailAddress": "Email Address", "superAdminOnly": "(Super Admin Only)", "projectAccess": "Project Access", "globalAccessAll": "Global (All Present & Future)", "restrictedSpecific": "Restricted (Select Specific)", "globalAccessInfo": "This user will have access to all projects by default and can switch between them using the project navigator.", "sendInvitation": "Send Invitation", "cancelInvitation": "Cancel Invitation?", "removeTeamMember": "Remove Team Member?", "cancelInviteConfirm": "Are you sure you want to cancel the invitation for {{name}}? They haven't joined the organisation yet.", "removeMemberConfirm": "Are you sure you want to remove {{name}}? They will lose all access to organisation data and projects.", "revokeInvitation": "Revoke Invitation", "removeAccess": "Remove Access", "inviteSent": "Invitation sent successfully!", "inviteFailed": "Failed to send invitation.", "inviteCancelled": "Invitation cancelled.", "memberRemoved": "User removed.", "actionFailed": "Action failed.", "acceptInvitation": "Accept Invitation", "invitedToJoin": "You've been invited to join", "setPassword": "Set Password", "minimumCharsHint": "Minimum 8 characters", "repeatPasswordHint": "Repeat password", "activating": "Activating...", "activateAccount": "Activate Account", "accountActivated": "Account activated! Redirecting...", "noTokenFound": "No invitation token found in the link.", "invalidInvite": "Invalid or expired invitation.", "passwordMismatch": "Passwords do not match.", "passwordTooShort": "Password must be at least 8 characters.", "activationFailed": "Failed to activate account.", - "enclosures": "Enclosures", "enclosure": "Enclosure", "areas": "Areas", "area": "Area", "enclosuresDescription": "Physical management of collections by site location.", "enableEnclosures": "Enable Enclosures", "enableAreas": "Enable Areas", "enableEnclosuresModuleDesc": "Enable advanced mapping and species grouping by physical location.", "orgWideView": "Organization-Wide View", "networkDescription": "Discover other organizations and establish breeding partnerships.", "myPartners": "My Partners", "plantMapDescription": "Spatial distribution and precise physical tracking of your botanical collection.", "allManagedAreas": "All Managed Areas", "projectScope": "Project Scope", "hideLabels": "Hide Labels", "showLabels": "Show Labels", "noProjectsFound": "No Projects Found" + "enclosures": "Enclosures", "enclosure": "Enclosure", "areas": "Areas", "area": "Area", "enclosuresDescription": "Physical management of collections by site location.", "enableEnclosures": "Enable Enclosures", "enableAreas": "Enable Areas", "enableEnclosuresModuleDesc": "Enable advanced mapping and species grouping by physical location.", "orgWideView": "Organization-Wide View", "networkDescription": "Discover other organizations and establish breeding partnerships.", "myPartners": "My Partners", "plantMapDescription": "Spatial distribution and precise physical tracking of your botanical collection.", "allManagedAreas": "All Managed Areas", "projectScope": "Project Scope", "hideLabels": "Hide Labels", "showLabels": "Show Labels", "noProjectsFound": "No Projects Found", + "history": "History", "genetics": "Genetics", "editProfile": "Edit Profile", "sex": "Sex", "birthDate": "Birth Date", "planted": "Planted", "deathDate": "Death Date", "removed": "Removed", "acquisitionSource": "Acquisition Source", "sourceDetails": "Source Details", "loanStatus": "Loan Status", "transferred": "Transferred", "transferNote": "Transfer Note", "lifeExpectancyShort": "Life Expectancy", "sexualMaturityShort": "Sexual Maturity", "avgAdultWeight": "Avg Adult Weight", "avgAdultHeight": "Avg Adult Height", "breedingSeason": "Breeding Season", "nativeStatus": "Native Status", "speciesInfo": "Species Info", "viewInSpeciesRegistry": "View in Species Registry", "location": "Location", "noCoordinatesAssigned": "No coordinates assigned", "setLocation": "Set Location", "weightTrend": "Weight Trend", "growthTrend": "Growth Trend", "logWeight": "Log Weight", "logHeight": "Log Height", "addObservation": "Add Observation", "parentage": "Parentage", "sire": "Sire", "dam": "Dam", "performedBy": "Performed By", "speciesDetail": "Species Detail", "weightHistory": "Weight History", "healthHistory": "Health History" } }, { @@ -70,7 +72,8 @@ "enablePage": "Aktifkan Fitur", "dashBlockTitle": "Judul Pesan Dasbor", "dashBlockContent": "Konten Pesan Dasbor", "customDashBlock": "Pengumuman Dasbor Kustom", "customDashBlockDesc": "Buat blok pengumuman yang muncul di bagian atas dasbor untuk semua pengguna.", "visibilityPrivacy": "Visibilitas & Privasi", "breedingLoanPolicy": "Kebijakan Pembiakan & Pinjaman", "allowBreedingRequests": "Izinkan Permintaan Jaringan", "allowBreedingRequestsDesc": "Izinkan organisasi mitra mengusulkan pinjaman pembiakan melalui peta jaringan.", "whoReceivesRequests": "Kontak Permintaan", "whoReceivesRequestsDesc": "Pengguna mana yang diberitahu saat permintaan pinjaman diterima?", "orgVisibility": "Daftarkan dalam Direktori", "orgVisibilityDesc": "Buat organisasi Anda terlihat di peta jaringan global.", "obscureLocation": "Samarkan Lokasi Peta", "obscureLocationDesc": "Bulatkan koordinat peta untuk mencegah pelacakan lokasi yang tepat.", "speciesListVisibility": "Daftar Spesies Publik", "speciesListVisibilityDesc": "Izinkan siapa saja di jaringan melihat spesies yang Anda kelola.", "noPartnersFound": "Tidak ada mitra ditemukan.", "connectNewPartner": "Hubungkan Mitra Baru", "yourInviteCode": "Kode Undangan Anda", "redeemCode": "Tukarkan Kode", "siteKey": "Kunci Situs", "secretKey": "Kunci Rahasia", "fullName": "Nama Lengkap", "preferredLanguage": "Bahasa Pilihan", "emailAddress": "Alamat Email", "superAdminOnly": "(Hanya Super Admin)", "projectAccess": "Akses Proyek", "globalAccessAll": "Global (Semua Sekarang & Masa Depan)", "restrictedSpecific": "Terbatas (Pilih Spesifik)", "globalAccessInfo": "Pengguna ini akan memiliki akses ke semua proyek secara default dan dapat beralih di antara mereka menggunakan navigator proyek.", "sendInvitation": "Kirim Undangan", "cancelInvitation": "Batalkan Undangan?", "removeTeamMember": "Hapus Anggota Tim?", "cancelInviteConfirm": "Apakah Anda yakin ingin membatalkan undangan untuk {{name}}? Mereka belum bergabung dengan organisasi.", "removeMemberConfirm": "Apakah Anda yakin ingin menghapus {{name}}? Mereka akan kehilangan semua akses ke data dan proyek organisasi.", "revokeInvitation": "Cabut Undangan", "removeAccess": "Hapus Akses", "inviteSent": "Undangan berhasil dikirim!", "inviteFailed": "Gagal mengirim undangan.", "inviteCancelled": "Undangan dibatalkan.", "memberRemoved": "Pengguna dihapus.", "actionFailed": "Tindakan gagal.", - "acceptInvitation": "Terima Undangan", "invitedToJoin": "Anda telah diundang untuk bergabung", "setPassword": "Atur Kata Sandi", "minimumCharsHint": "Minimal 8 karakter", "repeatPasswordHint": "Ulangi kata sandi", "activating": "Mengaktifkan...", "activateAccount": "Aktifkan Akun", "accountActivated": "Akun diaktifkan! Mengalihkan...", "noTokenFound": "Tidak ada token undangan yang ditemukan dalam tautan.", "invalidInvite": "Undangan tidak valid atau sudah kedaluwarsa.", "passwordMismatch": "Kata sandi tidak cocok.", "passwordTooShort": "Kata sandi harus minimal 8 karakter.", "activationFailed": "Gagal mengaktifkan akun." + "acceptInvitation": "Terima Undangan", "invitedToJoin": "Anda telah diundang untuk bergabung", "setPassword": "Atur Kata Sandi", "minimumCharsHint": "Minimal 8 karakter", "repeatPasswordHint": "Ulangi kata sandi", "activating": "Mengaktifkan...", "activateAccount": "Aktifkan Akun", "accountActivated": "Akun diaktifkan! Mengalihkan...", "noTokenFound": "Tidak ada token undangan yang ditemukan dalam tautan.", "invalidInvite": "Undangan tidak valid atau sudah kedaluwarsa.", "passwordMismatch": "Kata sandi tidak cocok.", "passwordTooShort": "Kata sandi harus minimal 8 karakter.", "activationFailed": "Gagal mengaktifkan akun.", + "history": "Riwayat", "genetics": "Genetika", "editProfile": "Edit Profil", "sex": "Jenis Kelamin", "birthDate": "Tanggal Lahir", "planted": "Ditanam", "deathDate": "Tanggal Kematian", "removed": "Disingkirkan", "acquisitionSource": "Sumber Perolehan", "sourceDetails": "Detail Sumber", "loanStatus": "Status Pinjaman", "transferred": "Dipindahkan", "transferNote": "Catatan Transfer", "lifeExpectancyShort": "Harapan Hidup", "sexualMaturityShort": "Kematangan Seksual", "avgAdultWeight": "Berat Dewasa Rata-rata", "avgAdultHeight": "Tinggi Dewasa Rata-rata", "breedingSeason": "Musim Kawin", "nativeStatus": "Status Asli", "speciesInfo": "Informasi Spesies", "viewInSpeciesRegistry": "Lihat di Registri", "location": "Lokasi", "noCoordinatesAssigned": "Tidak ada koordinat", "setLocation": "Tetapkan Lokasi", "weightTrend": "Tren Berat", "growthTrend": "Tren Pertumbuhan", "logWeight": "Catat Berat", "logHeight": "Catat Tinggi", "addObservation": "Tambah Observasi", "parentage": "Keturunan", "sire": "Ayah", "dam": "Induk", "performedBy": "Dilakukan Oleh", "speciesDetail": "Detail Spesies", "weightHistory": "Riwayat Berat", "healthHistory": "Riwayat Kesehatan" } }, { @@ -94,7 +97,8 @@ "enablePage": "Aktifkan Ciri", "dashBlockTitle": "Tajuk Mesej Papan Pemuka", "dashBlockContent": "Kandungan Mesej Papan Pemuka", "customDashBlock": "Pengumuman Papan Pemuka Tersuai", "customDashBlockDesc": "Cipta blok pengumuman yang muncul di bahagian atas papan pemuka untuk semua pengguna.", "visibilityPrivacy": "Keterlihatan & Privasi", "breedingLoanPolicy": "Dasar Pembiakan & Pinjaman", "allowBreedingRequests": "Benarkan Permintaan Rangkaian", "allowBreedingRequestsDesc": "Benarkan organisasi rakan kongsi mencadangkan pinjaman pembiakan melalui peta rangkaian.", "whoReceivesRequests": "Kenalan Permintaan", "whoReceivesRequestsDesc": "Pengguna mana yang akan diberitahu apabila permintaan pinjaman diterima?", "orgVisibility": "Senarai dalam Direktori", "orgVisibilityDesc": "Jadikan organisasi anda kelihatan pada peta rangkaian global.", "obscureLocation": "Samarkan Lokasi Peta", "obscureLocationDesc": "Bulatkan koordinat peta untuk mencegah penjejakan lokasi tepat.", "speciesListVisibility": "Senarai Spesies Awam", "speciesListVisibilityDesc": "Benarkan sesiapa dalam rangkaian melihat spesies yang anda urus.", "noPartnersFound": "Tiada rakan kongsi dijumpai.", "connectNewPartner": "Hubungkan Rakan Kongsi Baru", "yourInviteCode": "Kod Jemputan Anda", "redeemCode": "Tebus Kod", "siteKey": "Kunci Tapak", "secretKey": "Kunci Rahsia", "fullName": "Nama Penuh", "preferredLanguage": "Bahasa Pilihan", "emailAddress": "Alamat E-mel", "superAdminOnly": "(Hanya Super Admin)", "projectAccess": "Akses Projek", "globalAccessAll": "Global (Semua Kini & Masa Depan)", "restrictedSpecific": "Terhad (Pilih Spesifik)", "globalAccessInfo": "Pengguna ini akan mempunyai akses kepada semua projek secara lalai dan boleh bertukar di antara mereka menggunakan navigator projek.", "sendInvitation": "Hantar Jemputan", "cancelInvitation": "Batalkan Jemputan?", "removeTeamMember": "Buang Ahli Pasukan?", "cancelInviteConfirm": "Adakah anda pasti ingin membatalkan jemputan untuk {{name}}? Mereka belum menyertai organisasi.", "removeMemberConfirm": "Adakah anda pasti ingin membuang {{name}}? Mereka akan kehilangan semua akses kepada data dan projek organisasi.", "revokeInvitation": "Tarik Balik Jemputan", "removeAccess": "Buang Akses", "inviteSent": "Jemputan berjaya dihantar!", "inviteFailed": "Gagal menghantar jemputan.", "inviteCancelled": "Jemputan dibatalkan.", "memberRemoved": "Pengguna dibuang.", "actionFailed": "Tindakan gagal.", - "acceptInvitation": "Terima Jemputan", "invitedToJoin": "Anda telah dijemput untuk menyertai", "setPassword": "Tetapkan Kata Laluan", "minimumCharsHint": "Minimum 8 aksara", "repeatPasswordHint": "Ulang kata laluan", "activating": "Mengaktifkan...", "activateAccount": "Aktifkan Akaun", "accountActivated": "Akaun diaktifkan! Mengalihkan...", "noTokenFound": "Tiada token jemputan dijumpai dalam pautan.", "invalidInvite": "Jemputan tidak sah atau sudah tamat tempoh.", "passwordMismatch": "Kata laluan tidak sepadan.", "passwordTooShort": "Kata laluan mestilah sekurang-kurangnya 8 aksara.", "activationFailed": "Gagal mengaktifkan akaun." + "acceptInvitation": "Terima Jemputan", "invitedToJoin": "Anda telah dijemput untuk menyertai", "setPassword": "Tetapkan Kata Laluan", "minimumCharsHint": "Minimum 8 aksara", "repeatPasswordHint": "Ulang kata laluan", "activating": "Mengaktifkan...", "activateAccount": "Aktifkan Akaun", "accountActivated": "Akaun diaktifkan! Mengalihkan...", "noTokenFound": "Tiada token jemputan dijumpai dalam pautan.", "invalidInvite": "Jemputan tidak sah atau sudah tamat tempoh.", "passwordMismatch": "Kata laluan tidak sepadan.", "passwordTooShort": "Kata laluan mestilah sekurang-kurangnya 8 aksara.", "activationFailed": "Gagal mengaktifkan akaun.", + "history": "Sejarah", "genetics": "Genetik", "editProfile": "Edit Profil", "sex": "Jantina", "birthDate": "Tarikh Lahir", "planted": "Ditanam", "deathDate": "Tarikh Kematian", "removed": "Dibuang", "acquisitionSource": "Sumber Pemerolehan", "sourceDetails": "Butiran Sumber", "loanStatus": "Status Pinjaman", "transferred": "Dipindahkan", "transferNote": "Nota Pindahan", "lifeExpectancyShort": "Jangka Hayat", "sexualMaturityShort": "Kematangan Seksual", "avgAdultWeight": "Purata Berat Dewasa", "avgAdultHeight": "Purata Tinggi Dewasa", "breedingSeason": "Musim Pembiakan", "nativeStatus": "Status Asli", "speciesInfo": "Maklumat Spesies", "viewInSpeciesRegistry": "Lihat di Daftar", "location": "Lokasi", "noCoordinatesAssigned": "Tiada koordinat", "setLocation": "Tetapkan Lokasi", "weightTrend": "Aliran Berat", "growthTrend": "Aliran Pertumbuhan", "logWeight": "Log Berat", "logHeight": "Log Tinggi", "addObservation": "Tambah Pemerhatian", "parentage": "Keturunan", "sire": "Bapa", "dam": "Induk", "performedBy": "Dilakukan Oleh", "speciesDetail": "Butiran Spesies", "weightHistory": "Sejarah Berat", "healthHistory": "Sejarah Kesihatan" } }, { @@ -118,7 +122,8 @@ "enablePage": "Ativar Funcionalidade", "dashBlockTitle": "Título da Mensagem do Painel", "dashBlockContent": "Conteúdo da Mensagem do Painel", "customDashBlock": "Anúncio Personalizado do Painel", "customDashBlockDesc": "Crie um bloco de anúncio personalizado que aparece no topo do painel para todos os utilizadores.", "visibilityPrivacy": "Visibilidade & Privacidade", "breedingLoanPolicy": "Política de Reprodução & Empréstimo", "allowBreedingRequests": "Permitir Pedidos de Rede", "allowBreedingRequestsDesc": "Permitir que organizações parceiras proponham empréstimos de reprodução através do mapa de rede.", "whoReceivesRequests": "Contacto de Pedidos", "whoReceivesRequestsDesc": "Qual utilizador deve ser notificado quando um pedido de empréstimo é recebido?", "orgVisibility": "Listar no Diretório", "orgVisibilityDesc": "Tornar a sua organização visível no mapa de rede global.", "obscureLocation": "Ocultar Localização no Mapa", "obscureLocationDesc": "Arredonde as coordenadas do mapa para evitar rastreamento preciso por não parceiros.", "speciesListVisibility": "Lista de Espécies Pública", "speciesListVisibilityDesc": "Permitir que qualquer pessoa na rede veja as espécies que gere.", "noPartnersFound": "Nenhum parceiro encontrado.", "connectNewPartner": "Conectar Novo Parceiro", "yourInviteCode": "O Seu Código de Convite", "redeemCode": "Resgatar Código", "siteKey": "Chave do Site", "secretKey": "Chave Secreta", "fullName": "Nome Completo", "preferredLanguage": "Idioma Preferido", "emailAddress": "Endereço de E-mail", "superAdminOnly": "(Apenas Super Admin)", "projectAccess": "Acesso ao Projeto", "globalAccessAll": "Global (Todos Presentes e Futuros)", "restrictedSpecific": "Restrito (Selecionar Específicos)", "globalAccessInfo": "Este utilizador terá acesso a todos os projetos por padrão e pode alternar entre eles usando o navegador de projetos.", "sendInvitation": "Enviar Convite", "cancelInvitation": "Cancelar Convite?", "removeTeamMember": "Remover Membro da Equipa?", "cancelInviteConfirm": "Tem a certeza de que pretende cancelar o convite para {{name}}? Ainda não se juntou à organização.", "removeMemberConfirm": "Tem a certeza de que pretende remover {{name}}? Perderá todo o acesso aos dados e projetos da organização.", "revokeInvitation": "Revogar Convite", "removeAccess": "Remover Acesso", "inviteSent": "Convite enviado com sucesso!", "inviteFailed": "Falha ao enviar convite.", "inviteCancelled": "Convite cancelado.", "memberRemoved": "Utilizador removido.", "actionFailed": "Ação falhou.", - "acceptInvitation": "Aceitar Convite", "invitedToJoin": "Foi convidado a juntar-se", "setPassword": "Definir Palavra-passe", "minimumCharsHint": "Mínimo 8 caracteres", "repeatPasswordHint": "Repetir palavra-passe", "activating": "A ativar...", "activateAccount": "Ativar Conta", "accountActivated": "Conta ativada! A redirecionar...", "noTokenFound": "Nenhum token de convite encontrado no link.", "invalidInvite": "Convite inválido ou expirado.", "passwordMismatch": "As palavras-passe não coincidem.", "passwordTooShort": "A palavra-passe deve ter pelo menos 8 caracteres.", "activationFailed": "Falha ao ativar conta." + "acceptInvitation": "Aceitar Convite", "invitedToJoin": "Foi convidado a juntar-se", "setPassword": "Definir Palavra-passe", "minimumCharsHint": "Mínimo 8 caracteres", "repeatPasswordHint": "Repetir palavra-passe", "activating": "A ativar...", "activateAccount": "Ativar Conta", "accountActivated": "Conta ativada! A redirecionar...", "noTokenFound": "Nenhum token de convite encontrado no link.", "invalidInvite": "Convite inválido ou expirado.", "passwordMismatch": "As palavras-passe não coincidem.", "passwordTooShort": "A palavra-passe deve ter pelo menos 8 caracteres.", "activationFailed": "Falha ao ativar conta.", + "history": "Histórico", "genetics": "Genética", "editProfile": "Editar perfil", "sex": "Sexo", "birthDate": "Data de nascimento", "planted": "Plantado", "deathDate": "Data de falecimento", "removed": "Removido", "acquisitionSource": "Fonte de aquisição", "sourceDetails": "Detalhes da fonte", "loanStatus": "Estado do empréstimo", "transferred": "Transferido", "transferNote": "Nota de transferência", "lifeExpectancyShort": "Esperança de vida", "sexualMaturityShort": "Maturidade sexual", "avgAdultWeight": "Peso adulto médio", "avgAdultHeight": "Altura adulta média", "breedingSeason": "Época de reprodução", "nativeStatus": "Estado nativo", "speciesInfo": "Informações da espécie", "viewInSpeciesRegistry": "Ver no registo", "location": "Localização", "noCoordinatesAssigned": "Sem coordenadas atribuídas", "setLocation": "Definir localização", "weightTrend": "Tendência de peso", "growthTrend": "Tendência de crescimento", "logWeight": "Registar peso", "logHeight": "Registar altura", "addObservation": "Adicionar observação", "parentage": "Parentesco", "sire": "Pai", "dam": "Mãe", "performedBy": "Realizado por", "speciesDetail": "Detalhe da espécie", "weightHistory": "Histórico de peso", "healthHistory": "Histórico de saúde" } }, { @@ -142,7 +147,8 @@ "enablePage": "Activar Función", "dashBlockTitle": "Título del Mensaje del Panel", "dashBlockContent": "Contenido del Mensaje del Panel", "customDashBlock": "Anuncio Personalizado del Panel", "customDashBlockDesc": "Cree un bloque de anuncio personalizado que aparece en la parte superior del panel para todos los usuarios.", "visibilityPrivacy": "Visibilidad y Privacidad", "breedingLoanPolicy": "Política de Cría y Préstamo", "allowBreedingRequests": "Permitir Solicitudes de Red", "allowBreedingRequestsDesc": "Permitir que organizaciones socias propongan préstamos de cría a través del mapa de red.", "whoReceivesRequests": "Contacto de Solicitudes", "whoReceivesRequestsDesc": "¿Qué usuario debe ser notificado cuando se recibe una solicitud de préstamo?", "orgVisibility": "Listar en el Directorio", "orgVisibilityDesc": "Haga visible su organización en el mapa de red global.", "obscureLocation": "Ocultar Ubicación en el Mapa", "obscureLocationDesc": "Redondee las coordenadas del mapa para evitar el rastreo preciso por no socios.", "speciesListVisibility": "Lista de Especies Pública", "speciesListVisibilityDesc": "Permitir que cualquier persona en la red vea qué especies gestiona.", "noPartnersFound": "No se encontraron socios.", "connectNewPartner": "Conectar Nuevo Socio", "yourInviteCode": "Su Código de Invitación", "redeemCode": "Canjear Código", "siteKey": "Clave del Sitio", "secretKey": "Clave Secreta", "fullName": "Nombre Completo", "preferredLanguage": "Idioma Preferido", "emailAddress": "Dirección de Correo", "superAdminOnly": "(Solo Super Admin)", "projectAccess": "Acceso al Proyecto", "globalAccessAll": "Global (Todos Presentes y Futuros)", "restrictedSpecific": "Restringido (Seleccionar Específicos)", "globalAccessInfo": "Este usuario tendrá acceso a todos los proyectos por defecto y puede cambiar entre ellos usando el navegador de proyectos.", "sendInvitation": "Enviar Invitación", "cancelInvitation": "¿Cancelar Invitación?", "removeTeamMember": "¿Eliminar Miembro del Equipo?", "cancelInviteConfirm": "¿Está seguro de que desea cancelar la invitación para {{name}}? Aún no se ha unido a la organización.", "removeMemberConfirm": "¿Está seguro de que desea eliminar a {{name}}? Perderá todo acceso a los datos y proyectos de la organización.", "revokeInvitation": "Revocar Invitación", "removeAccess": "Eliminar Acceso", "inviteSent": "¡Invitación enviada con éxito!", "inviteFailed": "Error al enviar la invitación.", "inviteCancelled": "Invitación cancelada.", "memberRemoved": "Usuario eliminado.", "actionFailed": "La acción falló.", - "acceptInvitation": "Aceptar Invitación", "invitedToJoin": "Ha sido invitado a unirse", "setPassword": "Establecer Contraseña", "minimumCharsHint": "Mínimo 8 caracteres", "repeatPasswordHint": "Repetir contraseña", "activating": "Activando...", "activateAccount": "Activar Cuenta", "accountActivated": "¡Cuenta activada! Redirigiendo...", "noTokenFound": "No se encontró token de invitación en el enlace.", "invalidInvite": "Invitación inválida o expirada.", "passwordMismatch": "Las contraseñas no coinciden.", "passwordTooShort": "La contraseña debe tener al menos 8 caracteres.", "activationFailed": "Error al activar la cuenta." + "acceptInvitation": "Aceptar Invitación", "invitedToJoin": "Ha sido invitado a unirse", "setPassword": "Establecer Contraseña", "minimumCharsHint": "Mínimo 8 caracteres", "repeatPasswordHint": "Repetir contraseña", "activating": "Activando...", "activateAccount": "Activar Cuenta", "accountActivated": "¡Cuenta activada! Redirigiendo...", "noTokenFound": "No se encontró token de invitación en el enlace.", "invalidInvite": "Invitación inválida o expirada.", "passwordMismatch": "Las contraseñas no coinciden.", "passwordTooShort": "La contraseña debe tener al menos 8 caracteres.", "activationFailed": "Error al activar la cuenta.", + "history": "Historial", "genetics": "Genética", "editProfile": "Editar perfil", "sex": "Sexo", "birthDate": "Fecha de nacimiento", "planted": "Plantado", "deathDate": "Fecha de fallecimiento", "removed": "Eliminado", "acquisitionSource": "Fuente de adquisición", "sourceDetails": "Detalles de la fuente", "loanStatus": "Estado del préstamo", "transferred": "Transferido", "transferNote": "Nota de transferencia", "lifeExpectancyShort": "Esperanza de vida", "sexualMaturityShort": "Madurez sexual", "avgAdultWeight": "Peso adulto promedio", "avgAdultHeight": "Altura adulta promedio", "breedingSeason": "Temporada de cría", "nativeStatus": "Estado nativo", "speciesInfo": "Información de la especie", "viewInSpeciesRegistry": "Ver en el registro", "location": "Ubicación", "noCoordinatesAssigned": "Sin coordenadas asignadas", "setLocation": "Establecer ubicación", "weightTrend": "Tendencia de peso", "growthTrend": "Tendencia de crecimiento", "logWeight": "Registrar peso", "logHeight": "Registrar altura", "addObservation": "Añadir observación", "parentage": "Parentesco", "sire": "Padre", "dam": "Madre", "performedBy": "Realizado por", "speciesDetail": "Detalle de la especie", "weightHistory": "Historial de peso", "healthHistory": "Historial de salud" } }, { @@ -166,7 +172,8 @@ "enablePage": "Activer la fonctionnalité", "dashBlockTitle": "Titre du message du tableau de bord", "dashBlockContent": "Contenu du message du tableau de bord", "customDashBlock": "Annonce personnalisée du tableau de bord", "customDashBlockDesc": "Créez un bloc d'annonce personnalisé qui apparaît en haut du tableau de bord pour tous les utilisateurs.", "visibilityPrivacy": "Visibilité & Confidentialité", "breedingLoanPolicy": "Politique de reproduction & prêt", "allowBreedingRequests": "Autoriser les demandes réseau", "allowBreedingRequestsDesc": "Permettre aux organisations partenaires de proposer des prêts de reproduction via la carte réseau.", "whoReceivesRequests": "Contact des demandes", "whoReceivesRequestsDesc": "Quel utilisateur doit être notifié lorsqu'une demande de prêt est reçue ?", "orgVisibility": "Lister dans l'annuaire", "orgVisibilityDesc": "Rendre votre organisation visible sur la carte réseau mondiale.", "obscureLocation": "Masquer la localisation sur la carte", "obscureLocationDesc": "Arrondissez vos coordonnées de carte pour éviter le suivi précis par les non-partenaires.", "speciesListVisibility": "Liste d'espèces publique", "speciesListVisibilityDesc": "Permettre à quiconque sur le réseau de voir les espèces que vous gérez.", "noPartnersFound": "Aucun partenaire trouvé.", "connectNewPartner": "Connecter un nouveau partenaire", "yourInviteCode": "Votre code d'invitation", "redeemCode": "Échanger le code", "siteKey": "Clé du site", "secretKey": "Clé secrète", "fullName": "Nom complet", "preferredLanguage": "Langue préférée", "emailAddress": "Adresse e-mail", "superAdminOnly": "(Super Admin uniquement)", "projectAccess": "Accès au projet", "globalAccessAll": "Global (Tous présents et futurs)", "restrictedSpecific": "Restreint (Sélectionner spécifiques)", "globalAccessInfo": "Cet utilisateur aura accès à tous les projets par défaut et peut basculer entre eux en utilisant le navigateur de projets.", "sendInvitation": "Envoyer l'invitation", "cancelInvitation": "Annuler l'invitation ?", "removeTeamMember": "Supprimer le membre de l'équipe ?", "cancelInviteConfirm": "Êtes-vous sûr de vouloir annuler l'invitation pour {{name}} ? Il n'a pas encore rejoint l'organisation.", "removeMemberConfirm": "Êtes-vous sûr de vouloir supprimer {{name}} ? Il perdra tout accès aux données et projets de l'organisation.", "revokeInvitation": "Révoquer l'invitation", "removeAccess": "Supprimer l'accès", "inviteSent": "Invitation envoyée avec succès !", "inviteFailed": "Échec de l'envoi de l'invitation.", "inviteCancelled": "Invitation annulée.", "memberRemoved": "Utilisateur supprimé.", "actionFailed": "L'action a échoué.", - "acceptInvitation": "Accepter l'invitation", "invitedToJoin": "Vous avez été invité à rejoindre", "setPassword": "Définir le mot de passe", "minimumCharsHint": "Minimum 8 caractères", "repeatPasswordHint": "Répéter le mot de passe", "activating": "Activation en cours...", "activateAccount": "Activer le compte", "accountActivated": "Compte activé ! Redirection en cours...", "noTokenFound": "Aucun token d'invitation trouvé dans le lien.", "invalidInvite": "Invitation invalide ou expirée.", "passwordMismatch": "Les mots de passe ne correspondent pas.", "passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères.", "activationFailed": "Échec de l'activation du compte." + "acceptInvitation": "Accepter l'invitation", "invitedToJoin": "Vous avez été invité à rejoindre", "setPassword": "Définir le mot de passe", "minimumCharsHint": "Minimum 8 caractères", "repeatPasswordHint": "Répéter le mot de passe", "activating": "Activation en cours...", "activateAccount": "Activer le compte", "accountActivated": "Compte activé ! Redirection en cours...", "noTokenFound": "Aucun token d'invitation trouvé dans le lien.", "invalidInvite": "Invitation invalide ou expirée.", "passwordMismatch": "Les mots de passe ne correspondent pas.", "passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères.", "activationFailed": "Échec de l'activation du compte.", + "history": "Historique", "genetics": "Génétique", "editProfile": "Modifier le profil", "sex": "Sexe", "birthDate": "Date de naissance", "planted": "Planté", "deathDate": "Date de décès", "removed": "Retiré", "acquisitionSource": "Source d'acquisition", "sourceDetails": "Détails de la source", "loanStatus": "Statut de prêt", "transferred": "Transféré", "transferNote": "Note de transfert", "lifeExpectancyShort": "Espérance de vie", "sexualMaturityShort": "Maturité sexuelle", "avgAdultWeight": "Poids adulte moyen", "avgAdultHeight": "Hauteur adulte moyenne", "breedingSeason": "Saison de reproduction", "nativeStatus": "Statut d'indigénat", "speciesInfo": "Informations sur l'espèce", "viewInSpeciesRegistry": "Voir dans le registre", "location": "Localisation", "noCoordinatesAssigned": "Aucune coordonnée assignée", "setLocation": "Définir la localisation", "weightTrend": "Tendance du poids", "growthTrend": "Tendance de croissance", "logWeight": "Enregistrer le poids", "logHeight": "Enregistrer la hauteur", "addObservation": "Ajouter une observation", "parentage": "Filiation", "sire": "Père", "dam": "Mère", "performedBy": "Réalisé par", "speciesDetail": "Détail de l'espèce", "weightHistory": "Historique de poids", "healthHistory": "Historique de santé" } } ] diff --git a/components/SpeciesModal.tsx b/components/SpeciesModal.tsx new file mode 100644 index 0000000..6faf140 --- /dev/null +++ b/components/SpeciesModal.tsx @@ -0,0 +1,158 @@ +import React, { useContext } from 'react'; +import { Species } from '../types'; +import { X, ExternalLink, MapPin } from 'lucide-react'; +import { LanguageContext } from '../App'; +import { generatePattern } from '../services/storage'; + +const nativeStatusStyle = (status: string) => { + switch (status) { + case 'Native': return 'bg-green-100 text-green-700 border-green-200'; + case 'Introduced': return 'bg-amber-100 text-amber-700 border-amber-200'; + case 'Invasive': return 'bg-red-100 text-red-700 border-red-200'; + default: return 'bg-slate-100 text-slate-500 border-slate-200'; + } +}; + +interface SpeciesModalProps { + species: Species; + onClose: () => void; +} + +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + +const SpeciesModal: React.FC = ({ species, onClose }) => { + const { t } = useContext(LanguageContext); + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ + {/* Hero image */} +
+ {species.commonName} + {/* Conservation status badge */} + {species.conservationStatus && species.conservationStatus !== 'Unknown' && ( +
+ {species.conservationStatus} +
+ )} + {/* Flora/Fauna badge */} +
+ {species.type === 'Plant' ? t('plant') : t('animal')} +
+ {/* Close */} + +
+ + {/* Body */} +
+
+

{species.commonName}

+

{species.scientificName}

+ + {/* Description */} + {species.description && ( +

{species.description}

+ )} + + {/* Stats grid */} +
+ {(species.lifeExpectancyYears ?? 0) > 0 && ( +
+

{t('lifeExpectancyShort')}

+

{species.lifeExpectancyYears} {t('years')}

+
+ )} + {(species.sexualMaturityAgeYears ?? 0) > 0 && ( +
+

{t('sexualMaturityShort')}

+

{species.sexualMaturityAgeYears} {t('years')}

+
+ )} + {species.type !== 'Plant' && (species.averageAdultWeightKg ?? 0) > 0 && ( +
+

{t('avgAdultWeight')}

+

{species.averageAdultWeightKg} kg

+
+ )} + {species.type === 'Plant' && (species.averageAdultWeightKg ?? 0) > 0 && ( +
+

{t('avgAdultHeight')}

+

{species.averageAdultWeightKg} cm

+
+ )} + {species.breedingSeasonStart && species.breedingSeasonEnd && ( +
+

{t('breedingSeason')}

+

+ {MONTHS[species.breedingSeasonStart - 1]} – {MONTHS[species.breedingSeasonEnd - 1]} +

+
+ )} + {species.plantClassification && species.type === 'Plant' && ( +
+

{t('classification')}

+

{species.plantClassification}

+
+ )} +
+ + {/* Native status — single badge (local status; falls back to national) */} + {(() => { + const status = + (species.nativeStatusLocal && species.nativeStatusLocal !== 'Unknown') + ? species.nativeStatusLocal + : (species.nativeStatusCountry && species.nativeStatusCountry !== 'Unknown') + ? species.nativeStatusCountry + : null; + if (!status) return null; + return ( +
+ + + {status === 'Introduced' ? 'Non-Native' : status} + +
+ ); + })()} +
+ + {/* Footer links — hidden for unidentified species */} + {!species.isUnknown && ( + + )} +
+
+
+ ); +}; + +export default SpeciesModal; diff --git a/pages/IndividualDetail.tsx b/pages/IndividualDetail.tsx index ba2e9b3..c73ed9b 100644 --- a/pages/IndividualDetail.tsx +++ b/pages/IndividualDetail.tsx @@ -8,9 +8,21 @@ import { ArrowLeft, Scale, Activity, Syringe, Calendar, Plus, Stethoscope, Sprou import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'; import { LanguageContext } from '../App'; import ConfirmModal from '../components/ConfirmModal'; +import SpeciesModal from '../components/SpeciesModal'; declare const L: any; +const nativeStatusStyle = (status: string) => { + switch (status) { + case 'Native': return 'bg-green-100 text-green-700'; + case 'Introduced': return 'bg-amber-100 text-amber-700'; + case 'Invasive': return 'bg-red-100 text-red-600'; + default: return 'bg-slate-100 text-slate-400'; + } +}; +const nativeStatusLabel = (status: string) => + status === 'Introduced' ? 'Non-Native' : status; + const IndividualDetail: React.FC = () => { const { t } = useContext(LanguageContext); const { id } = useParams<{ id: string }>(); @@ -41,6 +53,7 @@ const IndividualDetail: React.FC = () => { const [showHealthModal, setShowHealthModal] = useState(false); const [galleryIndex, setGalleryIndex] = useState(-1); const [showDnaDeleteConfirm, setShowDnaDeleteConfirm] = useState(false); + const [showSpeciesModal, setShowSpeciesModal] = useState(false); // Form Media State const [pendingLogImage, setPendingLogImage] = useState(''); @@ -319,15 +332,15 @@ const IndividualDetail: React.FC = () => {
{/* Tabs */}
- - - + + +
{activeTab === 'overview' && ( @@ -343,52 +356,52 @@ const IndividualDetail: React.FC = () => {
- Studbook ID + {t('studbookId')} {individual.studbookId}
{(!(isPlant && individual.sex === Sex.UNKNOWN)) && (
- Sex + {t('sex')} {individual.sex}
)}
- {isPlant ? 'Planted' : 'Birth Date'} - {individual.birthDate || 'Unknown'} + {isPlant ? t('planted') : t('birthDate')} + {individual.birthDate || t('unknownSex')}
{individual.isDeceased && individual.deathDate && (
- {isPlant ? 'Removed' : 'Death Date'} + {isPlant ? t('removed') : t('deathDate')} {individual.deathDate}
)} {individual.source && (
- Source + {t('acquisitionSource')} {individual.source}
)} {individual.sourceDetails && (
- Source Details + {t('sourceDetails')}

{individual.sourceDetails}

)} {individual.loanStatus && individual.loanStatus !== 'None' && (
- Loan Status + {t('loanStatus')} {individual.loanStatus}
)} {individual.transferDate && (
- Transferred + {t('transferred')} {individual.transferDate}
)} {individual.transferNote && (
- Transfer Note + {t('transferNote')}

{individual.transferNote}

)} @@ -396,7 +409,7 @@ const IndividualDetail: React.FC = () => {
-

Enclosure

+

{t('enclosure')}

{enclosure.name}

@@ -425,7 +438,9 @@ const IndividualDetail: React.FC = () => { {species && weightData.length <= 1 && (
- +

Species: {species.commonName}

{species.scientificName} {species.conservationStatus && species.conservationStatus !== 'Unknown' && ( @@ -435,46 +450,66 @@ const IndividualDetail: React.FC = () => {
{species.lifeExpectancyYears > 0 && (
-

Life Expectancy

+

{t('lifeExpectancyShort')}

{species.lifeExpectancyYears} yrs

)} {species.sexualMaturityAgeYears > 0 && (
-

Sexual Maturity

+

{t('sexualMaturityShort')}

{species.sexualMaturityAgeYears} yrs

)} {species.averageAdultWeightKg > 0 && (
-

Avg Adult {isPlant ? 'Height' : 'Weight'}

+

{isPlant ? t('avgAdultHeight') : t('avgAdultWeight')}

{species.averageAdultWeightKg} {isPlant ? 'cm' : 'kg'}

)} {species.breedingSeasonStart && species.breedingSeasonEnd && (
-

Breeding Season

+

{t('breedingSeason')}

{['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][species.breedingSeasonStart - 1]} – {['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][species.breedingSeasonEnd - 1]}

)} - {species.nativeStatusCountry && species.nativeStatusCountry !== 'Unknown' && ( -
-

Native Status

-

{species.nativeStatusCountry}

-
- )} + {(() => { + const ns = (species.nativeStatusLocal && species.nativeStatusLocal !== 'Unknown') + ? species.nativeStatusLocal + : (species.nativeStatusCountry && species.nativeStatusCountry !== 'Unknown') + ? species.nativeStatusCountry + : null; + return ns ? ( +
+

{t('nativeStatus')}

+ {nativeStatusLabel(ns)} +
+ ) : null; + })()}
+ {species.description && ( +
+

{species.description}

+
+ )}
- - Wikipedia - - - IUCN Red List - + {!species.isUnknown && ( + <> + + Wikipedia + + + IUCN Red List + + + )} +
)} @@ -483,17 +518,17 @@ const IndividualDetail: React.FC = () => { {(weightData.length > 1 || (!species || weightData.length > 1)) && (
-

{isPlant ? 'Growth Trend' : 'Weight Trend'}

+

{isPlant ? t('growthTrend') : t('weightTrend')}

{isPlant && weightData.length > 0 && ( - + )} - +
{isPlant && weightData.length === 0 ? (
-

Species Info

+

{t('speciesInfo')}

Scientific Name

@@ -508,8 +543,17 @@ const IndividualDetail: React.FC = () => {

{species?.conservationStatus || '—'}

-

Native Status

-

{species?.nativeStatusLocal || species?.nativeStatusCountry || '—'}

+

{t('nativeStatus')}

+ {(() => { + const ns = (species?.nativeStatusLocal && species.nativeStatusLocal !== 'Unknown') + ? species.nativeStatusLocal + : (species?.nativeStatusCountry && species.nativeStatusCountry !== 'Unknown') + ? species.nativeStatusCountry + : null; + return ns + ? {nativeStatusLabel(ns)} + :

; + })()}
@@ -540,7 +584,9 @@ const IndividualDetail: React.FC = () => { {species && weightData.length > 1 && (
- +

Species: {species.commonName}

{species.scientificName} {species.conservationStatus && species.conservationStatus !== 'Unknown' && ( @@ -550,61 +596,81 @@ const IndividualDetail: React.FC = () => {
{species.lifeExpectancyYears > 0 && (
-

Life Expectancy

+

{t('lifeExpectancyShort')}

{species.lifeExpectancyYears} yrs

)} {species.sexualMaturityAgeYears > 0 && (
-

Sexual Maturity

+

{t('sexualMaturityShort')}

{species.sexualMaturityAgeYears} yrs

)} {species.averageAdultWeightKg > 0 && (
-

Avg Adult {isPlant ? 'Height' : 'Weight'}

+

{isPlant ? t('avgAdultHeight') : t('avgAdultWeight')}

{species.averageAdultWeightKg} {isPlant ? 'cm' : 'kg'}

)} {species.breedingSeasonStart && species.breedingSeasonEnd && (
-

Breeding Season

+

{t('breedingSeason')}

{['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][species.breedingSeasonStart - 1]} – {['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][species.breedingSeasonEnd - 1]}

)} - {species.nativeStatusCountry && species.nativeStatusCountry !== 'Unknown' && ( -
-

Native Status

-

{species.nativeStatusCountry}

-
- )} + {(() => { + const ns = (species.nativeStatusLocal && species.nativeStatusLocal !== 'Unknown') + ? species.nativeStatusLocal + : (species.nativeStatusCountry && species.nativeStatusCountry !== 'Unknown') + ? species.nativeStatusCountry + : null; + return ns ? ( +
+

{t('nativeStatus')}

+ {nativeStatusLabel(ns)} +
+ ) : null; + })()}
+ {species.description && ( +
+

{species.description}

+
+ )}
- - Wikipedia - - - IUCN Red List - + {!species.isUnknown && ( + <> + + Wikipedia + + + IUCN Red List + + + )} +
)} - {(species?.type === 'Animal' || individual.sireId || individual.damId) && ( + {(individual.sireId || individual.damId) && (
-

Parentage

+

{t('parentage')}

S
-

Sire

{individual.sireId || 'Unknown'}

+

{t('sire')}

{individual.sireId || t('unknownSex')}

D
-

Dam

{individual.damId || 'Unknown'}

+

{t('dam')}

{individual.damId || t('unknownSex')}

@@ -612,7 +678,7 @@ const IndividualDetail: React.FC = () => { {/* Location — full width */}
-

Location

+

{t('location')}

{individual.latitude ? ( @@ -642,7 +708,7 @@ const IndividualDetail: React.FC = () => { ) : (
-

No coordinates assigned

+

{t('noCoordinatesAssigned')}

{locationError &&

{locationError}

} @@ -683,7 +749,7 @@ const IndividualDetail: React.FC = () => { {log.date}

{log.description}

- {log.performedBy &&

Performed by: {log.performedBy}

} + {log.performedBy &&

{t('performedBy')}: {log.performedBy}

} {log.imageUrl && (
{ @@ -960,6 +1026,10 @@ const IndividualDetail: React.FC = () => { }} onCancel={() => setShowDnaDeleteConfirm(false)} /> + + {showSpeciesModal && species && ( + setShowSpeciesModal(false)} /> + )}
); }; diff --git a/pages/IndividualManager.tsx b/pages/IndividualManager.tsx index 51c5f37..074696b 100644 --- a/pages/IndividualManager.tsx +++ b/pages/IndividualManager.tsx @@ -10,6 +10,17 @@ import ConfirmModal from '../components/ConfirmModal'; declare const L: any; +const nativeStatusStyle = (status: string) => { + switch (status) { + case 'Native': return 'bg-green-100 text-green-700'; + case 'Introduced': return 'bg-amber-100 text-amber-700'; + case 'Invasive': return 'bg-red-100 text-red-600'; + default: return 'bg-slate-100 text-slate-400'; + } +}; +const nativeStatusLabel = (status: string) => + status === 'Introduced' ? 'Non-Native' : status; + type ViewMode = 'grid' | 'list' | 'map'; interface IndividualManagerProps { @@ -42,9 +53,12 @@ const IndividualManager: React.FC = ({ currentProjectId, const [isSpeciesDropdownOpen, setIsSpeciesDropdownOpen] = useState(false); const [addLocation, setAddLocation] = useState(false); - // Unknown Species picker + // Unidentified species form const [showUnknownPicker, setShowUnknownPicker] = useState(false); const [isCreatingUnknown, setIsCreatingUnknown] = useState(false); + const [unknownType, setUnknownType] = useState('Animal'); + const [unknownName, setUnknownName] = useState(''); + const [unknownDesc, setUnknownDesc] = useState(''); // Presence-only mode (species generally present, no specific individual tracked) const [presenceOnly, setPresenceOnly] = useState(false); @@ -415,16 +429,7 @@ 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); - // 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); + setAllSpecies(getSpecies()); setAllProjects(projs); setAllEnclosures(getEnclosures()); const currentOrg = getOrg(); @@ -650,6 +655,9 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, setSpeciesSearchQuery(''); setAddLocation(false); setShowUnknownPicker(false); + setUnknownType('Animal'); + setUnknownName(''); + setUnknownDesc(''); setPresenceOnly(false); setShowForm(true); }; @@ -731,46 +739,41 @@ 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 => { + /** Create a new unique unidentified-species record for this individual */ + const handleRegisterUnidentified = async () => { 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, - }; + if (!targetProjectId) { alert('Select a project first.'); return; } - 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); + const imageUrl = unknownType === 'Animal' ? '/unknown-fauna.svg' : '/unknown-flora.svg'; + const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const defaultName = unknownType === 'Animal' ? `Unidentified Fauna #${uid.slice(-4).toUpperCase()}` : `Unidentified Flora #${uid.slice(-4).toUpperCase()}`; + + const newSpecies: Species = { + id: `unid-${unknownType.toLowerCase()}-${uid}`, + projectId: targetProjectId, + commonName: unknownName.trim() || defaultName, + scientificName: 'Species incognita', + type: unknownType, + conservationStatus: 'Unknown', + sexualMaturityAgeYears: 0, + lifeExpectancyYears: 0, + averageAdultWeightKg: 0, + description: unknownDesc.trim() || undefined, + imageUrl, + isUnknown: true, + }; + + const updated = [...allSpecies, newSpecies]; + await saveSpecies(updated); + setAllSpecies(updated); + setFormData(prev => ({ ...prev, speciesId: newSpecies.id })); + setSpeciesSearchQuery(newSpecies.commonName); setIsSpeciesDropdownOpen(false); setShowUnknownPicker(false); + setUnknownName(''); + setUnknownDesc(''); } catch (e: any) { alert(e.message); } finally { @@ -1018,16 +1021,26 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, {ind.studbookId} — {sp?.commonName} -

{sp?.scientificName}

+

{sp?.scientificName}

) : ( <> {ind.name} -

{sp?.commonName}

+

{sp?.commonName}

)} + {(() => { + const ns = (sp?.nativeStatusLocal && sp.nativeStatusLocal !== 'Unknown') + ? sp.nativeStatusLocal + : (sp?.nativeStatusCountry && sp.nativeStatusCountry !== 'Unknown') + ? sp.nativeStatusCountry + : null; + return ns ? ( + {nativeStatusLabel(ns)} + ) : null; + })()}
{ind.studbookId} @@ -1078,7 +1091,19 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, {ind.isPresenceOnly && Presence} {ind.isDeceased && Deceased} - {isPlantInd ? '' : sp?.commonName} + + {isPlantInd ? '' : sp?.commonName} + {(() => { + const ns = (sp?.nativeStatusLocal && sp.nativeStatusLocal !== 'Unknown') + ? sp.nativeStatusLocal + : (sp?.nativeStatusCountry && sp.nativeStatusCountry !== 'Unknown') + ? sp.nativeStatusCountry + : null; + return ns ? ( + {nativeStatusLabel(ns)} + ) : null; + })()} + {ind.studbookId}
@@ -1456,21 +1481,51 @@ SB-2024-002,African Elephant,Loxodonta africana,Babar,Male,2015-07-04,3200,,,,,, Species not yet identified? ) : ( -
- Register as: +
+
+ Register unidentified species + +
+ {/* Fauna / Flora toggle */} +
+ + +
+ {/* Working name */} +
+ + setUnknownName(e.target.value)} + placeholder={`e.g. "Spotted bird near river"`} + className="w-full px-3 py-1.5 text-xs border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-emerald-400 bg-white" + /> +
+ {/* Description */} +
+ +