diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py index 23df35f..c49031f 100644 --- a/src/animaltrack/web/routes/events.py +++ b/src/animaltrack/web/routes/events.py @@ -247,29 +247,60 @@ def event_log_index(request: Request, htmx): ) -def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]: +def get_event_animals(db: Any, event_id: str, limit: int | None = None) -> list[dict[str, Any]]: """Get animals affected by an event with display info. Args: db: Database connection. event_id: Event ID to look up animals for. + limit: Maximum number of animals to return (None for all). Returns: - List of animal dicts with id, nickname, species_name. + List of animal dicts with id, nickname, species_name, sex, life_stage, location_name. """ - rows = db.execute( - """ - SELECT ar.animal_id, ar.nickname, s.name as species_name + query = """ + SELECT ar.animal_id, ar.nickname, s.name as species_name, + ar.sex, ar.life_stage, l.name as location_name FROM event_animals ea JOIN animal_registry ar ON ar.animal_id = ea.animal_id JOIN species s ON s.code = ar.species_code + LEFT JOIN locations l ON l.id = ar.location_id WHERE ea.event_id = ? ORDER BY ar.nickname NULLS LAST, ar.animal_id - """, - (event_id,), - ).fetchall() + """ + if limit: + query += f" LIMIT {limit}" - return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows] + rows = db.execute(query, (event_id,)).fetchall() + + return [ + { + "id": row[0], + "nickname": row[1], + "species_name": row[2], + "sex": row[3], + "life_stage": row[4], + "location_name": row[5], + } + for row in rows + ] + + +def get_event_animal_count(db: Any, event_id: str) -> int: + """Get count of animals affected by an event. + + Args: + db: Database connection. + event_id: Event ID to count animals for. + + Returns: + Total number of animals affected. + """ + row = db.execute( + "SELECT COUNT(*) FROM event_animals WHERE event_id = ?", + (event_id,), + ).fetchone() + return row[0] if row else 0 @ar("/events/{event_id}") @@ -292,8 +323,9 @@ def event_detail(request: Request, event_id: str, htmx): # Check if tombstoned is_tombstoned = event_store.is_tombstoned(event_id) - # Get affected animals - affected_animals = get_event_animals(db, event_id) + # Get affected animals (limited to first 20 for performance) + affected_animals = get_event_animals(db, event_id, limit=20) + total_animal_count = get_event_animal_count(db, event_id) # Get location names if entity_refs has location IDs location_names = {} @@ -317,7 +349,9 @@ def event_detail(request: Request, event_id: str, htmx): user_role = auth.role if auth else None # Build the panel - panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role) + panel = event_detail_panel( + event, affected_animals, total_animal_count, is_tombstoned, location_names, user_role + ) # HTMX request (slide-over) → return just panel if htmx.request: @@ -327,6 +361,24 @@ def event_detail(request: Request, event_id: str, htmx): return render_page(request, panel, title=f"Event {event.id}") +@ar("/events/{event_id}/animals") +def event_animals_all(request: Request, event_id: str): + """GET /events/{event_id}/animals - Get all affected animals for an event. + + This endpoint is used via HTMX to load the full list when user clicks "Show all". + """ + from animaltrack.web.templates.event_detail import affected_animals_list + + db = request.app.state.db + + # Get all animals (no limit) + animals = get_event_animals(db, event_id) + total_count = len(animals) + + # Return just the list component for HTMX swap + return affected_animals_list(animals, total_count, expanded=True) + + @ar("/events/{event_id}/delete", methods=["POST"]) async def event_delete(request: Request, event_id: str): """POST /events/{event_id}/delete - Delete an event (admin only). diff --git a/src/animaltrack/web/templates/event_detail.py b/src/animaltrack/web/templates/event_detail.py index 5ebe28a..1880b02 100644 --- a/src/animaltrack/web/templates/event_detail.py +++ b/src/animaltrack/web/templates/event_detail.py @@ -20,6 +20,7 @@ def format_timestamp(ts_utc: int) -> str: def event_detail_panel( event: Event, affected_animals: list[dict[str, Any]], + total_animal_count: int = 0, is_tombstoned: bool = False, location_names: dict[str, str] | None = None, user_role: UserRole | None = None, @@ -28,7 +29,8 @@ def event_detail_panel( Args: event: The event to display. - affected_animals: List of animals affected by this event. + affected_animals: List of animals affected by this event (may be limited). + total_animal_count: Total number of affected animals. is_tombstoned: Whether the event has been deleted. location_names: Map of location IDs to names. user_role: User's role for delete button visibility. @@ -38,6 +40,8 @@ def event_detail_panel( """ if location_names is None: location_names = {} + if total_animal_count == 0: + total_animal_count = len(affected_animals) return Div( # Header with close button @@ -69,7 +73,7 @@ def event_detail_panel( # Entity references entity_refs_section(event.entity_refs, location_names), # Affected animals - affected_animals_section(affected_animals), + affected_animals_section(affected_animals, total_animal_count, event.id), # Delete button (admin only, not for tombstoned events) delete_section(event.id) if user_role == UserRole.ADMIN and not is_tombstoned else None, id="event-panel-content", @@ -355,20 +359,76 @@ def entity_refs_section( ) -def affected_animals_section(animals: list[dict[str, Any]]) -> Div: - """Section showing affected animals.""" - if not animals: +def affected_animals_section( + animals: list[dict[str, Any]], + total_count: int, + event_id: str, +) -> Div: + """Section showing affected animals with expandable list. + + Args: + animals: List of animals to display (may be limited). + total_count: Total number of affected animals. + event_id: Event ID for "Show all" button. + + Returns: + Div containing the affected animals section. + """ + if not animals and total_count == 0: return Div() + return Div( + H3( + f"Affected Animals ({total_count})", + cls="text-sm font-semibold text-stone-400 mb-2", + ), + affected_animals_list(animals, total_count, event_id=event_id), + cls="p-4", + id="affected-animals-section", + ) + + +def affected_animals_list( + animals: list[dict[str, Any]], + total_count: int, + event_id: str | None = None, + expanded: bool = False, +) -> Div: + """List of affected animals with optional expand button. + + Args: + animals: List of animals to display. + total_count: Total number of affected animals. + event_id: Event ID for "Show all" button (None if expanded). + expanded: Whether showing full list. + + Returns: + Div containing the animal list. + """ animal_items = [] - for animal in animals[:20]: # Limit display + for animal in animals: display_name = format_animal_id(animal["id"], animal.get("nickname")) + + # Build details string: sex abbreviation, life stage, location + sex_abbr = {"male": "M", "female": "F", "unknown": "?"}.get( + animal.get("sex", "unknown"), "?" + ) + life_stage = animal.get("life_stage", "").replace("_", " ") + location = animal.get("location_name", "") + + details_parts = [sex_abbr] + if life_stage: + details_parts.append(life_stage) + if location: + details_parts.append(location) + details_str = ", ".join(details_parts) + animal_items.append( Li( A( - Span(display_name, cls="text-amber-500 hover:underline"), + Span(display_name, cls="text-amber-500 hover:underline mr-1"), Span( - f" ({animal.get('species_name', '')})", + f"({details_str})", cls="text-stone-500 text-xs", ), href=f"/animals/{animal['id']}", @@ -377,22 +437,23 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div: ) ) - more_count = len(animals) - 20 - if more_count > 0: - animal_items.append( - Li( - Span(f"... and {more_count} more", cls="text-stone-500 text-sm"), - cls="py-1", - ) + # Show "Show all X animals" button if there are more + more_count = total_count - len(animals) + show_all_button = None + if more_count > 0 and not expanded and event_id: + show_all_button = Button( + f"Show all {total_count} animals", + hx_get=f"/events/{event_id}/animals", + hx_target="#affected-animals-list", + hx_swap="outerHTML", + cls="mt-2 text-sm text-amber-500 hover:text-amber-400 hover:underline", + type="button", ) return Div( - H3( - f"Affected Animals ({len(animals)})", - cls="text-sm font-semibold text-stone-400 mb-2", - ), Ul(*animal_items, cls="space-y-1"), - cls="p-4", + show_all_button, + id="affected-animals-list", ) diff --git a/src/animaltrack/web/templates/registry.py b/src/animaltrack/web/templates/registry.py index a33244a..2c15d1e 100644 --- a/src/animaltrack/web/templates/registry.py +++ b/src/animaltrack/web/templates/registry.py @@ -5,7 +5,24 @@ from datetime import UTC, datetime from typing import Any from urllib.parse import urlencode -from fasthtml.common import H2, A, Div, Form, Input, P, Span, Table, Tbody, Td, Th, Thead, Tr +from fasthtml.common import ( + H2, + A, + Div, + Form, + Input, + Li, + P, + Script, + Span, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + Ul, +) from monsterui.all import Button, ButtonT, FormLabel, Grid from animaltrack.id_gen import format_animal_id @@ -43,9 +60,11 @@ def registry_page( Grid( # Sidebar with facets facet_sidebar(facets, filter_str, locations, species_list), - # Main content - table + # Main content - selection toolbar + table Div( + selection_toolbar(), animal_table(animals, next_cursor, filter_str), + selection_script(), cls="col-span-3", ), cols_sm=1, @@ -203,6 +222,14 @@ def animal_table( return Table( Thead( Tr( + Th( + Input( + type="checkbox", + id="select-all-checkbox", + cls="uk-checkbox", + ), + cls="w-8", + ), Th("ID", shrink=True), Th("Species"), Th("Sex"), @@ -254,6 +281,13 @@ def animal_row(animal: AnimalListItem) -> Tr: tags_str += "..." return Tr( + Td( + Input( + type="checkbox", + cls="uk-checkbox animal-checkbox", + data_animal_id=animal.animal_id, + ), + ), Td( A( display_id, @@ -319,10 +353,208 @@ def load_more_sentinel(cursor: str, filter_str: str) -> Tr: "Loading more...", cls="text-center text-stone-400 py-4", ), - colspan="8", + colspan="9", # Updated for checkbox column ), hx_get=url, hx_trigger="revealed", hx_swap="outerHTML", id="load-more-sentinel", ) + + +def selection_toolbar() -> Div: + """Toolbar for bulk actions on selected animals. + + Returns: + Div with selection count and actions dropdown. + """ + return Div( + # Left side: selection info and controls + Div( + Span("0 selected", id="selection-count", cls="text-sm text-stone-400"), + A( + "Select all", + href="#", + id="select-all-btn", + cls="text-sm text-amber-500 hover:underline ml-3", + ), + A( + "Clear", + href="#", + id="clear-selection-btn", + cls="text-sm text-stone-400 hover:text-stone-200 ml-3 hidden", + ), + cls="flex items-center", + ), + # Right side: actions dropdown + Div( + Button( + "Actions", + id="actions-btn", + cls=f"{ButtonT.default} px-4", + disabled=True, + ), + Div( + Ul( + Li( + A( + "Move", + href="#", + data_action="move", + cls="block px-4 py-2 hover:bg-stone-700", + ) + ), + Li( + A( + "Add Tag", + href="#", + data_action="tag-add", + cls="block px-4 py-2 hover:bg-stone-700", + ) + ), + Li( + A( + "Update Attributes", + href="#", + data_action="attrs", + cls="block px-4 py-2 hover:bg-stone-700", + ) + ), + Li( + A( + "Record Outcome", + href="#", + data_action="outcome", + cls="block px-4 py-2 hover:bg-stone-700", + ) + ), + cls="uk-nav uk-dropdown-nav", + ), + uk_dropdown="mode: click; pos: bottom-right", + cls="uk-dropdown", + ), + cls="uk-inline", + ), + cls="flex justify-between items-center mb-4 py-2 px-3 bg-stone-800/50 rounded", + id="selection-toolbar", + ) + + +def selection_script() -> Script: + """JavaScript for handling animal selection. + + Returns: + Script element with selection logic. + """ + return Script(""" + (function() { + const selectedIds = new Set(); + + function updateUI() { + const count = selectedIds.size; + document.getElementById('selection-count').textContent = count + ' selected'; + + const actionsBtn = document.getElementById('actions-btn'); + actionsBtn.disabled = count === 0; + + const clearBtn = document.getElementById('clear-selection-btn'); + if (count > 0) { + clearBtn.classList.remove('hidden'); + } else { + clearBtn.classList.add('hidden'); + } + + // Update all checkboxes to reflect state + document.querySelectorAll('.animal-checkbox').forEach(cb => { + cb.checked = selectedIds.has(cb.dataset.animalId); + }); + + // Update header checkbox + const headerCb = document.getElementById('select-all-checkbox'); + const allCheckboxes = document.querySelectorAll('.animal-checkbox'); + if (allCheckboxes.length > 0) { + const allChecked = Array.from(allCheckboxes).every(cb => selectedIds.has(cb.dataset.animalId)); + const someChecked = selectedIds.size > 0; + headerCb.checked = allChecked; + headerCb.indeterminate = someChecked && !allChecked; + } + } + + // Handle individual checkbox changes + document.addEventListener('change', function(e) { + if (e.target.classList.contains('animal-checkbox')) { + const animalId = e.target.dataset.animalId; + if (e.target.checked) { + selectedIds.add(animalId); + } else { + selectedIds.delete(animalId); + } + updateUI(); + } + }); + + // Handle header checkbox (select all visible) + document.getElementById('select-all-checkbox').addEventListener('change', function(e) { + document.querySelectorAll('.animal-checkbox').forEach(cb => { + if (e.target.checked) { + selectedIds.add(cb.dataset.animalId); + } else { + selectedIds.delete(cb.dataset.animalId); + } + }); + updateUI(); + }); + + // Select all button + document.getElementById('select-all-btn').addEventListener('click', function(e) { + e.preventDefault(); + document.querySelectorAll('.animal-checkbox').forEach(cb => { + selectedIds.add(cb.dataset.animalId); + }); + updateUI(); + }); + + // Clear selection button + document.getElementById('clear-selection-btn').addEventListener('click', function(e) { + e.preventDefault(); + selectedIds.clear(); + updateUI(); + }); + + // Handle action clicks + document.querySelectorAll('[data-action]').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + if (selectedIds.size === 0) return; + + const action = this.dataset.action; + const filter = 'animal_id:' + Array.from(selectedIds).join('|'); + + let url; + switch(action) { + case 'move': + url = '/move?filter=' + encodeURIComponent(filter); + break; + case 'tag-add': + url = '/actions/tag-add?filter=' + encodeURIComponent(filter); + break; + case 'attrs': + url = '/actions/attrs?filter=' + encodeURIComponent(filter); + break; + case 'outcome': + url = '/actions/outcome?filter=' + encodeURIComponent(filter); + break; + } + if (url) { + window.location.href = url; + } + }); + }); + + // Re-run updateUI after HTMX swaps (for infinite scroll) + document.body.addEventListener('htmx:afterSwap', function(e) { + // After new rows are loaded, restore checkbox states + updateUI(); + }); + })(); + """)