# ABOUTME: Templates for Move Animals form. # ABOUTME: Provides form components for moving animals with selection context and mismatch handling. from collections.abc import Callable from typing import Any from fasthtml.common import H2, A, Div, Form, Hidden, Option, P, Select, Span from monsterui.all import Alert, AlertT, Button, ButtonT, FormLabel, LabelInput, LabelTextArea from ulid import ULID from animaltrack.models.events import Event from animaltrack.models.reference import Location from animaltrack.selection.validation import SelectionDiff from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.recent_events import recent_events_section def move_form( locations: list[Location], filter_str: str = "", resolved_ids: list[str] | None = None, roster_hash: str = "", from_location_id: str | None = None, ts_utc: int | None = None, resolved_count: int = 0, from_location_name: str | None = None, error: str | None = None, action: Callable[..., Any] | str = "/actions/animal-move", animals: list | None = None, recent_events: list[tuple[Event, bool]] | None = None, days_since_last_move: int | None = None, location_names: dict[str, str] | None = None, ) -> Div: """Create the Move Animals form. Args: locations: List of active locations for the dropdown. filter_str: Current filter string (DSL). resolved_ids: Resolved animal IDs from filter. roster_hash: Hash of resolved selection. from_location_id: Common source location ID (if all animals from same location). ts_utc: Timestamp of resolution. resolved_count: Number of resolved animals. from_location_name: Name of source location for display. error: Optional error message to display. action: Route function or URL string for form submission. animals: List of AnimalListItem for checkbox selection (optional). recent_events: Recent (Event, is_deleted) tuples, most recent first. days_since_last_move: Number of days since the last move event. location_names: Dict mapping location_id to location name. Returns: Div containing form and recent events section. """ from animaltrack.web.templates.animal_select import animal_checkbox_list if resolved_ids is None: resolved_ids = [] if animals is None: animals = [] if recent_events is None: recent_events = [] if location_names is None: location_names = {} # Build destination location options (exclude from_location if set) location_options = [Option("Select destination...", value="", disabled=True, selected=True)] for loc in locations: if loc.id != from_location_id: location_options.append(Option(loc.name, value=loc.id)) # Error display component error_component = None if error: error_component = Alert( error, cls=AlertT.warning, ) # Selection component - show checkboxes if animals provided and > 1 # Wrapped in a container with ID for HTMX targeting selection_content = None if animals and len(animals) > 1: # Show checkbox list for subset selection location_info = f" from {from_location_name}" if from_location_name else "" selection_content = Div( P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"), animal_checkbox_list(animals, resolved_ids), ) elif resolved_count > 0: location_info = f" from {from_location_name}" if from_location_name else "" selection_content = Div( P( Span(f"{resolved_count}", cls="font-bold text-lg"), f" animals selected{location_info}", cls="text-sm", ), cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md", ) elif filter_str: selection_content = Div( P("No animals match this filter", cls="text-sm text-amber-600"), cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md", ) # Container for HTMX updates - always present so it can be targeted selection_container = Div( selection_content, id="selection-container", cls="mb-4" if selection_content else "", ) # Hidden fields for resolved_ids (as multiple values) resolved_id_fields = [ Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids ] # Format function for move events # Note: entity_refs stores animal_ids, not resolved_ids def format_move_event(event: Event) -> tuple[str, str]: to_loc_id = event.entity_refs.get("to_location_id", "") to_loc_name = location_names.get(to_loc_id, "Unknown") count = len(event.entity_refs.get("animal_ids", [])) return f"{count} animals to {to_loc_name}", event.id # Build stats text stat_text = None if days_since_last_move is not None: if days_since_last_move == 0: stat_text = "Last move: today" elif days_since_last_move == 1: stat_text = "Last move: yesterday" else: stat_text = f"Last move: {days_since_last_move} days ago" form = Form( H2("Move Animals", cls="text-xl font-bold mb-4"), # Error message if present error_component, # Filter input with HTMX to fetch selection preview LabelInput( "Filter", id="filter", name="filter", value=filter_str, placeholder='e.g., location:"Strip 1" species:duck', hx_get="/api/selection-preview", hx_trigger="change, keyup delay:500ms changed", hx_target="#selection-container", hx_swap="innerHTML", ), # Selection container - updated via HTMX when filter changes selection_container, # Destination dropdown - using raw Select due to MonsterUI LabelSelect value bug Div( FormLabel("Destination", _for="to_location_id"), Select(*location_options, name="to_location_id", id="to_location_id", cls="uk-select"), cls="space-y-2", ), # Optional notes LabelTextArea( "Notes", id="notes", name="notes", placeholder="Optional notes", ), # Event datetime picker (for backdating) event_datetime_field("move_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), Hidden(name="from_location_id", value=from_location_id or ""), Hidden(name="resolver_version", value="v1"), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), # Submit button Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", cls="space-y-4", ) return Div( form, recent_events_section( title="Recent Moves", events=recent_events, format_fn=format_move_event, stat_text=stat_text, ), ) def diff_panel( diff: SelectionDiff, filter_str: str, resolved_ids: list[str], roster_hash: str, from_location_id: str, to_location_id: str, ts_utc: int, locations: list[Location], action: Callable[..., Any] | str = "/actions/animal-move", ) -> Div: """Create the mismatch confirmation panel. Shows diff information and allows user to confirm or cancel. Args: diff: SelectionDiff with added/removed counts. filter_str: Original filter string. resolved_ids: Server's resolved IDs (current). roster_hash: Server's roster hash (current). from_location_id: Source location ID. to_location_id: Destination location ID. ts_utc: Timestamp for resolution. locations: List of locations for display. action: Route function or URL for confirmation submit. Returns: Div containing the diff panel with confirm button. """ # Find destination location name to_location_name = "Unknown" for loc in locations: if loc.id == to_location_id: to_location_name = loc.name break # Build description of changes changes = [] if diff.removed: changes.append(f"{len(diff.removed)} animals were moved since you loaded this page") if diff.added: changes.append(f"{len(diff.added)} animals were added") changes_text = ". ".join(changes) + "." if changes else "The selection has changed." # Build confirmation form with hidden fields resolved_id_fields = [ Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids ] confirm_form = Form( *resolved_id_fields, Hidden(name="filter", value=filter_str), Hidden(name="roster_hash", value=roster_hash), Hidden(name="from_location_id", value=from_location_id), Hidden(name="to_location_id", value=to_location_id), Hidden(name="ts_utc", value=str(ts_utc)), Hidden(name="resolver_version", value="v1"), Hidden(name="confirmed", value="true"), Hidden(name="nonce", value=str(ULID())), Div( A( "Cancel", href="/move", cls=ButtonT.default, ), Button( f"Confirm Move ({diff.server_count} animals)", type="submit", cls=ButtonT.primary, hx_disabled_elt="this", ), cls="flex gap-3 mt-4", ), action=action, method="post", ) return Div( Alert( Div( P("Selection Changed", cls="font-bold text-lg mb-2"), P(changes_text, cls="mb-2"), P( f"Would you like to proceed with the remaining {diff.server_count} animals to {to_location_name}?", cls="text-sm", ), ), cls=AlertT.warning, ), confirm_form, cls="space-y-4", )