Compare commits
9 Commits
eee8552345
...
5be8da96f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be8da96f2 | |||
| 803169816b | |||
| 7315e552e3 | |||
| 4e78b79745 | |||
| fc4c2a8e40 | |||
| b2132a8ef5 | |||
| a87b5cbac6 | |||
| b09d3088eb | |||
| 2fc98155c3 |
53
src/animaltrack/static/v1/datetime-picker.js
Normal file
53
src/animaltrack/static/v1/datetime-picker.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Datetime Picker Component
|
||||||
|
*
|
||||||
|
* Provides toggle and conversion functionality for backdating events.
|
||||||
|
* Uses data attributes to identify related elements.
|
||||||
|
*
|
||||||
|
* Expected HTML structure:
|
||||||
|
* - Toggle element: data-datetime-toggle="<field_id>"
|
||||||
|
* - Picker container: data-datetime-picker="<field_id>"
|
||||||
|
* - Input element: data-datetime-input="<field_id>"
|
||||||
|
* - Hidden ts_utc field: data-datetime-ts="<field_id>"
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the datetime picker visibility.
|
||||||
|
* @param {string} fieldId - The unique field ID prefix.
|
||||||
|
*/
|
||||||
|
function toggleDatetimePicker(fieldId) {
|
||||||
|
var picker = document.querySelector('[data-datetime-picker="' + fieldId + '"]');
|
||||||
|
var input = document.querySelector('[data-datetime-input="' + fieldId + '"]');
|
||||||
|
var tsField = document.querySelector('[data-datetime-ts="' + fieldId + '"]');
|
||||||
|
var toggle = document.querySelector('[data-datetime-toggle="' + fieldId + '"]');
|
||||||
|
|
||||||
|
if (!picker || !toggle) return;
|
||||||
|
|
||||||
|
if (picker.style.display === 'none') {
|
||||||
|
picker.style.display = 'block';
|
||||||
|
toggle.textContent = 'Use current time';
|
||||||
|
} else {
|
||||||
|
picker.style.display = 'none';
|
||||||
|
toggle.textContent = 'Set custom date';
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (tsField) tsField.value = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the hidden ts_utc field when datetime input changes.
|
||||||
|
* @param {string} fieldId - The unique field ID prefix.
|
||||||
|
*/
|
||||||
|
function updateDatetimeTs(fieldId) {
|
||||||
|
var input = document.querySelector('[data-datetime-input="' + fieldId + '"]');
|
||||||
|
var tsField = document.querySelector('[data-datetime-ts="' + fieldId + '"]');
|
||||||
|
|
||||||
|
if (!tsField) return;
|
||||||
|
|
||||||
|
if (input && input.value) {
|
||||||
|
var date = new Date(input.value);
|
||||||
|
tsField.value = date.getTime().toString();
|
||||||
|
} else {
|
||||||
|
tsField.value = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ from animaltrack.selection import compute_roster_hash, parse_filter, resolve_fil
|
|||||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||||
from animaltrack.services.animal import AnimalService, ValidationError
|
from animaltrack.services.animal import AnimalService, ValidationError
|
||||||
from animaltrack.web.auth import UserRole, require_role
|
from animaltrack.web.auth import UserRole, require_role
|
||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page, render_page_post
|
||||||
from animaltrack.web.templates.actions import (
|
from animaltrack.web.templates.actions import (
|
||||||
attrs_diff_panel,
|
attrs_diff_panel,
|
||||||
attrs_form,
|
attrs_form,
|
||||||
@@ -206,9 +206,11 @@ async def animal_cohort(request: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
cohort_form(locations, species_list),
|
cohort_form(locations, species_list),
|
||||||
|
push_url="/actions/cohort",
|
||||||
title="Create Cohort - AnimalTrack",
|
title="Create Cohort - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
@@ -349,9 +351,11 @@ async def hatch_recorded(request: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
hatch_form(locations, species_list),
|
hatch_form(locations, species_list),
|
||||||
|
push_url="/actions/hatch",
|
||||||
title="Record Hatch - AnimalTrack",
|
title="Record Hatch - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
@@ -690,9 +694,11 @@ async def animal_tag_add(request: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
tag_add_form(),
|
tag_add_form(),
|
||||||
|
push_url="/actions/tag-add",
|
||||||
title="Add Tag - AnimalTrack",
|
title="Add Tag - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
@@ -939,9 +945,11 @@ async def animal_tag_end(request: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
tag_end_form(),
|
tag_end_form(),
|
||||||
|
push_url="/actions/tag-end",
|
||||||
title="End Tag - AnimalTrack",
|
title="End Tag - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
@@ -1175,9 +1183,11 @@ async def animal_attrs(request: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
attrs_form(),
|
attrs_form(),
|
||||||
|
push_url="/actions/attrs",
|
||||||
title="Update Attributes - AnimalTrack",
|
title="Update Attributes - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
@@ -1455,10 +1465,11 @@ async def animal_outcome(request: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
product_repo = ProductRepository(db)
|
product_repo = ProductRepository(db)
|
||||||
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
|
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
|
||||||
|
|
||||||
return render_page(
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
outcome_form(
|
outcome_form(
|
||||||
filter_str="",
|
filter_str="",
|
||||||
@@ -1468,6 +1479,7 @@ async def animal_outcome(request: Request, session):
|
|||||||
resolved_count=0,
|
resolved_count=0,
|
||||||
products=products,
|
products=products,
|
||||||
),
|
),
|
||||||
|
push_url="/actions/outcome",
|
||||||
title="Record Outcome - AnimalTrack",
|
title="Record Outcome - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
@@ -1678,7 +1690,8 @@ async def animal_status_correct(req: Request, session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Success: re-render fresh form
|
# Success: re-render fresh form
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
req,
|
req,
|
||||||
status_correct_form(
|
status_correct_form(
|
||||||
filter_str="",
|
filter_str="",
|
||||||
@@ -1687,6 +1700,7 @@ async def animal_status_correct(req: Request, session):
|
|||||||
ts_utc=int(time.time() * 1000),
|
ts_utc=int(time.time() * 1000),
|
||||||
resolved_count=0,
|
resolved_count=0,
|
||||||
),
|
),
|
||||||
|
push_url="/actions/status-correct",
|
||||||
title="Correct Status - AnimalTrack",
|
title="Correct Status - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from animaltrack.repositories.products import ProductRepository
|
|||||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||||
from animaltrack.repositories.users import UserRepository
|
from animaltrack.repositories.users import UserRepository
|
||||||
from animaltrack.services.products import ProductService, ValidationError
|
from animaltrack.services.products import ProductService, ValidationError
|
||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page, render_page_post
|
||||||
from animaltrack.web.templates.eggs import eggs_page
|
from animaltrack.web.templates.eggs import eggs_page
|
||||||
|
|
||||||
# 30 days in milliseconds
|
# 30 days in milliseconds
|
||||||
@@ -365,7 +365,14 @@ async def product_collected(request: Request, session):
|
|||||||
# Validate location_id
|
# Validate location_id
|
||||||
if not location_id:
|
if not location_id:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, db, locations, products, None, "Please select a location"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
None,
|
||||||
|
"Please select a location",
|
||||||
|
quantity=quantity_str,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate quantity
|
# Validate quantity
|
||||||
@@ -373,12 +380,26 @@ async def product_collected(request: Request, session):
|
|||||||
quantity = int(quantity_str)
|
quantity = int(quantity_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, db, locations, products, location_id, "Quantity must be a number"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
location_id,
|
||||||
|
"Quantity must be a number",
|
||||||
|
quantity=quantity_str,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if quantity < 0:
|
if quantity < 0:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, db, locations, products, location_id, "Quantity cannot be negative"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
location_id,
|
||||||
|
"Quantity cannot be negative",
|
||||||
|
quantity=quantity_str,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get timestamp - use provided or current (supports backdating)
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
@@ -389,7 +410,14 @@ async def product_collected(request: Request, session):
|
|||||||
|
|
||||||
if not resolved_ids:
|
if not resolved_ids:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, db, locations, products, location_id, "No ducks at this location"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
location_id,
|
||||||
|
"No ducks at this location",
|
||||||
|
quantity=quantity_str,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create product service
|
# Create product service
|
||||||
@@ -422,7 +450,16 @@ async def product_collected(request: Request, session):
|
|||||||
route="/actions/product-collected",
|
route="/actions/product-collected",
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_harvest_error(request, db, locations, products, location_id, str(e))
|
return _render_harvest_error(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
location_id,
|
||||||
|
str(e),
|
||||||
|
quantity=quantity_str,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
# Save user defaults (only if user exists in database)
|
# Save user defaults (only if user exists in database)
|
||||||
if UserRepository(db).get(actor):
|
if UserRepository(db).get(actor):
|
||||||
@@ -446,7 +483,8 @@ async def product_collected(request: Request, session):
|
|||||||
display_data = _get_eggs_display_data(db, locations)
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
|
|
||||||
# Success: re-render form with location sticking, qty cleared
|
# Success: re-render form with location sticking, qty cleared
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
eggs_page(
|
eggs_page(
|
||||||
locations,
|
locations,
|
||||||
@@ -457,6 +495,7 @@ async def product_collected(request: Request, session):
|
|||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
|
push_url="/",
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
)
|
)
|
||||||
@@ -486,19 +525,48 @@ async def product_sold(request: Request, session):
|
|||||||
|
|
||||||
# Validate product_code
|
# Validate product_code
|
||||||
if not product_code:
|
if not product_code:
|
||||||
return _render_sell_error(request, db, locations, products, None, "Please select a product")
|
return _render_sell_error(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
None,
|
||||||
|
"Please select a product",
|
||||||
|
quantity=quantity_str,
|
||||||
|
total_price_cents=total_price_str,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
# Validate quantity
|
# Validate quantity
|
||||||
try:
|
try:
|
||||||
quantity = int(quantity_str)
|
quantity = int(quantity_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, db, locations, products, product_code, "Quantity must be a number"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
product_code,
|
||||||
|
"Quantity must be a number",
|
||||||
|
quantity=quantity_str,
|
||||||
|
total_price_cents=total_price_str,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if quantity < 1:
|
if quantity < 1:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, db, locations, products, product_code, "Quantity must be at least 1"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
product_code,
|
||||||
|
"Quantity must be at least 1",
|
||||||
|
quantity=quantity_str,
|
||||||
|
total_price_cents=total_price_str,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate total_price_cents
|
# Validate total_price_cents
|
||||||
@@ -506,12 +574,30 @@ async def product_sold(request: Request, session):
|
|||||||
total_price_cents = int(total_price_str)
|
total_price_cents = int(total_price_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, db, locations, products, product_code, "Total price must be a number"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
product_code,
|
||||||
|
"Total price must be a number",
|
||||||
|
quantity=quantity_str,
|
||||||
|
total_price_cents=total_price_str,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_price_cents < 0:
|
if total_price_cents < 0:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, db, locations, products, product_code, "Total price cannot be negative"
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
product_code,
|
||||||
|
"Total price cannot be negative",
|
||||||
|
quantity=quantity_str,
|
||||||
|
total_price_cents=total_price_str,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get timestamp - use provided or current (supports backdating)
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
@@ -544,7 +630,18 @@ async def product_sold(request: Request, session):
|
|||||||
route="/actions/product-sold",
|
route="/actions/product-sold",
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_sell_error(request, db, locations, products, product_code, str(e))
|
return _render_sell_error(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
product_code,
|
||||||
|
str(e),
|
||||||
|
quantity=quantity_str,
|
||||||
|
total_price_cents=total_price_str,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
# Add success toast with link to event
|
# Add success toast with link to event
|
||||||
add_toast(
|
add_toast(
|
||||||
@@ -557,7 +654,8 @@ async def product_sold(request: Request, session):
|
|||||||
display_data = _get_eggs_display_data(db, locations)
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
|
|
||||||
# Success: re-render form with product sticking
|
# Success: re-render form with product sticking
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
eggs_page(
|
eggs_page(
|
||||||
locations,
|
locations,
|
||||||
@@ -568,13 +666,23 @@ async def product_sold(request: Request, session):
|
|||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
|
push_url="/",
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_harvest_error(request, db, locations, products, selected_location_id, error_message):
|
def _render_harvest_error(
|
||||||
"""Render harvest form with error message.
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
selected_location_id,
|
||||||
|
error_message,
|
||||||
|
quantity: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
):
|
||||||
|
"""Render harvest form with error message and preserved field values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The HTTP request.
|
request: The HTTP request.
|
||||||
@@ -583,6 +691,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
|
|||||||
products: List of sellable products.
|
products: List of sellable products.
|
||||||
selected_location_id: Currently selected location.
|
selected_location_id: Currently selected location.
|
||||||
error_message: Error message to display.
|
error_message: Error message to display.
|
||||||
|
quantity: Quantity value to preserve.
|
||||||
|
notes: Notes value to preserve.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with 422 status.
|
HTMLResponse with 422 status.
|
||||||
@@ -600,6 +710,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
|
|||||||
harvest_error=error_message,
|
harvest_error=error_message,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
harvest_quantity=quantity,
|
||||||
|
harvest_notes=notes,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
@@ -610,8 +722,19 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_sell_error(request, db, locations, products, selected_product_code, error_message):
|
def _render_sell_error(
|
||||||
"""Render sell form with error message.
|
request,
|
||||||
|
db,
|
||||||
|
locations,
|
||||||
|
products,
|
||||||
|
selected_product_code,
|
||||||
|
error_message,
|
||||||
|
quantity: str | None = None,
|
||||||
|
total_price_cents: str | None = None,
|
||||||
|
buyer: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
):
|
||||||
|
"""Render sell form with error message and preserved field values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The HTTP request.
|
request: The HTTP request.
|
||||||
@@ -620,6 +743,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
|
|||||||
products: List of sellable products.
|
products: List of sellable products.
|
||||||
selected_product_code: Currently selected product code.
|
selected_product_code: Currently selected product code.
|
||||||
error_message: Error message to display.
|
error_message: Error message to display.
|
||||||
|
quantity: Quantity value to preserve.
|
||||||
|
total_price_cents: Total price value to preserve.
|
||||||
|
buyer: Buyer value to preserve.
|
||||||
|
notes: Notes value to preserve.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with 422 status.
|
HTMLResponse with 422 status.
|
||||||
@@ -637,6 +764,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
|
|||||||
sell_error=error_message,
|
sell_error=error_message,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
sell_quantity=quantity,
|
||||||
|
sell_total_price_cents=total_price_cents,
|
||||||
|
sell_buyer=buyer,
|
||||||
|
sell_notes=notes,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from animaltrack.repositories.locations import LocationRepository
|
|||||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||||
from animaltrack.repositories.users import UserRepository
|
from animaltrack.repositories.users import UserRepository
|
||||||
from animaltrack.services.feed import FeedService, ValidationError
|
from animaltrack.services.feed import FeedService, ValidationError
|
||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page, render_page_post
|
||||||
from animaltrack.web.templates.feed import feed_page
|
from animaltrack.web.templates.feed import feed_page
|
||||||
|
|
||||||
# 30 days in milliseconds
|
# 30 days in milliseconds
|
||||||
@@ -498,7 +498,8 @@ async def feed_given(request: Request, session):
|
|||||||
display_data = _get_feed_display_data(db, locations, feed_types)
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
|
|
||||||
# Success: re-render form with location/type sticking, amount reset
|
# Success: re-render form with location/type sticking, amount reset
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
feed_page(
|
feed_page(
|
||||||
locations,
|
locations,
|
||||||
@@ -512,6 +513,7 @@ async def feed_given(request: Request, session):
|
|||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
|
push_url="/feed",
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
)
|
)
|
||||||
@@ -666,7 +668,8 @@ async def feed_purchased(request: Request, session):
|
|||||||
display_data = _get_feed_display_data(db, locations, feed_types)
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
|
|
||||||
# Success: re-render form with fields cleared
|
# Success: re-render form with fields cleared
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
feed_page(
|
feed_page(
|
||||||
locations,
|
locations,
|
||||||
@@ -676,6 +679,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
|
push_url="/feed",
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from animaltrack.repositories.locations import LocationRepository
|
|||||||
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
||||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||||
from animaltrack.services.animal import AnimalService, ValidationError
|
from animaltrack.services.animal import AnimalService, ValidationError
|
||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page, render_page_post
|
||||||
from animaltrack.web.templates.move import diff_panel, move_form
|
from animaltrack.web.templates.move import diff_panel, move_form
|
||||||
|
|
||||||
# Milliseconds per day
|
# Milliseconds per day
|
||||||
@@ -396,13 +396,15 @@ async def animal_move(request: Request, session):
|
|||||||
display_data = _get_move_display_data(db, locations)
|
display_data = _get_move_display_data(db, locations)
|
||||||
|
|
||||||
# Success: re-render fresh form (nothing sticks per spec)
|
# Success: re-render fresh form (nothing sticks per spec)
|
||||||
return render_page(
|
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||||
|
return render_page_post(
|
||||||
request,
|
request,
|
||||||
move_form(
|
move_form(
|
||||||
locations,
|
locations,
|
||||||
action=animal_move,
|
action=animal_move,
|
||||||
**display_data,
|
**display_data,
|
||||||
),
|
),
|
||||||
|
push_url="/move",
|
||||||
title="Move - AnimalTrack",
|
title="Move - AnimalTrack",
|
||||||
active_nav="move",
|
active_nav="move",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ABOUTME: Templates package for AnimalTrack web UI.
|
# ABOUTME: Templates package for AnimalTrack web UI.
|
||||||
# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI.
|
# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI.
|
||||||
|
|
||||||
from animaltrack.web.templates.base import page, render_page
|
from animaltrack.web.templates.base import page, render_page, render_page_post
|
||||||
from animaltrack.web.templates.nav import BottomNav
|
from animaltrack.web.templates.nav import BottomNav
|
||||||
|
|
||||||
__all__ = ["page", "render_page", "BottomNav"]
|
__all__ = ["page", "render_page", "render_page_post", "BottomNav"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Select, Span
|
from fasthtml.common import H2, H3, A, Div, Form, Hidden, Input, Option, P, Script, Select, Span
|
||||||
from monsterui.all import (
|
from monsterui.all import (
|
||||||
Alert,
|
Alert,
|
||||||
AlertT,
|
AlertT,
|
||||||
@@ -12,7 +12,6 @@ from monsterui.all import (
|
|||||||
ButtonT,
|
ButtonT,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
LabelInput,
|
LabelInput,
|
||||||
LabelSelect,
|
|
||||||
LabelTextArea,
|
LabelTextArea,
|
||||||
)
|
)
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
@@ -21,6 +20,103 @@ from animaltrack.models.animals import Animal
|
|||||||
from animaltrack.models.reference import Location, Species
|
from animaltrack.models.reference import Location, Species
|
||||||
from animaltrack.selection.validation import SelectionDiff
|
from animaltrack.selection.validation import SelectionDiff
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Selection Diff Confirmation Panel
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def diff_confirmation_panel(
|
||||||
|
diff: SelectionDiff,
|
||||||
|
filter_str: str,
|
||||||
|
resolved_ids: list[str],
|
||||||
|
roster_hash: str,
|
||||||
|
ts_utc: int,
|
||||||
|
action: Callable[..., Any] | str,
|
||||||
|
action_hidden_fields: list[tuple[str, str]],
|
||||||
|
cancel_url: str,
|
||||||
|
confirm_button_text: str,
|
||||||
|
question_text: str,
|
||||||
|
confirm_button_cls: str = ButtonT.primary,
|
||||||
|
) -> Div:
|
||||||
|
"""Create a confirmation panel for selection mismatch scenarios.
|
||||||
|
|
||||||
|
This is a reusable component for all action forms that use optimistic locking.
|
||||||
|
When the client's selection differs from the server's current state, this panel
|
||||||
|
shows what changed and asks for confirmation before proceeding.
|
||||||
|
|
||||||
|
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).
|
||||||
|
ts_utc: Timestamp for resolution.
|
||||||
|
action: Route function or URL for confirmation submit.
|
||||||
|
action_hidden_fields: List of (name, value) tuples for action-specific fields.
|
||||||
|
cancel_url: URL for the cancel button.
|
||||||
|
confirm_button_text: Text for the confirm button.
|
||||||
|
question_text: Question shown in the alert (e.g. "Would you like to...").
|
||||||
|
confirm_button_cls: Button style class (default: ButtonT.primary).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div containing the diff panel with confirm button.
|
||||||
|
"""
|
||||||
|
# Build description of changes
|
||||||
|
changes = []
|
||||||
|
if diff.removed:
|
||||||
|
changes.append(f"{len(diff.removed)} animals were removed 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
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build action-specific hidden fields
|
||||||
|
action_fields = [Hidden(name=name, value=value) for name, value in action_hidden_fields]
|
||||||
|
|
||||||
|
confirm_form = Form(
|
||||||
|
*resolved_id_fields,
|
||||||
|
Hidden(name="filter", value=filter_str),
|
||||||
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
|
*action_fields,
|
||||||
|
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||||
|
Hidden(name="confirmed", value="true"),
|
||||||
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
|
Div(
|
||||||
|
A(
|
||||||
|
"Cancel",
|
||||||
|
href=cancel_url,
|
||||||
|
cls=ButtonT.default,
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
confirm_button_text,
|
||||||
|
type="submit",
|
||||||
|
cls=confirm_button_cls,
|
||||||
|
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(question_text, cls="text-sm"),
|
||||||
|
),
|
||||||
|
cls=AlertT.warning,
|
||||||
|
),
|
||||||
|
confirm_form,
|
||||||
|
cls="space-y-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Event Datetime Picker Component
|
# Event Datetime Picker Component
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -45,43 +141,14 @@ def event_datetime_field(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the datetime picker with toggle functionality.
|
Div containing the datetime picker with toggle functionality.
|
||||||
"""
|
"""
|
||||||
picker_id = f"{field_id}_picker"
|
|
||||||
input_id = f"{field_id}_input"
|
|
||||||
ts_utc_id = f"{field_id}_ts_utc"
|
|
||||||
|
|
||||||
# If initial value is set, start with picker expanded
|
# If initial value is set, start with picker expanded
|
||||||
has_initial = bool(initial_value)
|
has_initial = bool(initial_value)
|
||||||
picker_style = "display: block;" if has_initial else "display: none;"
|
picker_style = "display: block;" if has_initial else "display: none;"
|
||||||
toggle_text = "Use current time" if has_initial else "Set custom date"
|
toggle_text = "Use current time" if has_initial else "Set custom date"
|
||||||
|
|
||||||
# Inline JavaScript for toggle click handler
|
|
||||||
toggle_onclick = f"""
|
|
||||||
var picker = document.getElementById('{picker_id}');
|
|
||||||
var input = document.getElementById('{input_id}');
|
|
||||||
var tsField = document.getElementById('{ts_utc_id}');
|
|
||||||
if (picker.style.display === 'none') {{
|
|
||||||
picker.style.display = 'block';
|
|
||||||
this.textContent = 'Use current time';
|
|
||||||
}} else {{
|
|
||||||
picker.style.display = 'none';
|
|
||||||
this.textContent = 'Set custom date';
|
|
||||||
input.value = '';
|
|
||||||
if (tsField) tsField.value = '0';
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Inline JavaScript for input change handler
|
|
||||||
input_onchange = f"""
|
|
||||||
var tsField = document.getElementById('{ts_utc_id}');
|
|
||||||
if (tsField && this.value) {{
|
|
||||||
var date = new Date(this.value);
|
|
||||||
tsField.value = date.getTime().toString();
|
|
||||||
}} else if (tsField) {{
|
|
||||||
tsField.value = '0';
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
|
# Load static JS for datetime picker functionality
|
||||||
|
Script(src="/static/v1/datetime-picker.js"),
|
||||||
FormLabel("Event Time"),
|
FormLabel("Event Time"),
|
||||||
Div(
|
Div(
|
||||||
P(
|
P(
|
||||||
@@ -90,29 +157,30 @@ def event_datetime_field(
|
|||||||
Span(
|
Span(
|
||||||
toggle_text,
|
toggle_text,
|
||||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
||||||
hx_on_click=toggle_onclick,
|
data_datetime_toggle=field_id,
|
||||||
|
hx_on_click=f"toggleDatetimePicker('{field_id}')",
|
||||||
),
|
),
|
||||||
cls="text-sm",
|
cls="text-sm",
|
||||||
),
|
),
|
||||||
Div(
|
Div(
|
||||||
Input(
|
Input(
|
||||||
id=input_id,
|
|
||||||
name=f"{field_id}_value",
|
name=f"{field_id}_value",
|
||||||
type="datetime-local",
|
type="datetime-local",
|
||||||
value=initial_value,
|
value=initial_value,
|
||||||
cls="uk-input w-full mt-2",
|
cls="uk-input w-full mt-2",
|
||||||
hx_on_change=input_onchange,
|
data_datetime_input=field_id,
|
||||||
|
hx_on_change=f"updateDatetimeTs('{field_id}')",
|
||||||
),
|
),
|
||||||
P(
|
P(
|
||||||
"Select date/time for this event (leave empty for current time)",
|
"Select date/time for this event (leave empty for current time)",
|
||||||
cls="text-xs text-stone-500 mt-1",
|
cls="text-xs text-stone-500 mt-1",
|
||||||
),
|
),
|
||||||
id=picker_id,
|
data_datetime_picker=field_id,
|
||||||
style=picker_style,
|
style=picker_style,
|
||||||
),
|
),
|
||||||
cls="mt-1",
|
cls="mt-1",
|
||||||
),
|
),
|
||||||
Hidden(id=ts_utc_id, name="ts_utc", value=initial_ts),
|
Hidden(name="ts_utc", value=initial_ts, data_datetime_ts=field_id),
|
||||||
cls="space-y-1",
|
cls="space-y-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -215,19 +283,17 @@ def cohort_form(
|
|||||||
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
|
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
# Species dropdown
|
# Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*species_options,
|
FormLabel("Species", _for="species"),
|
||||||
label="Species",
|
Select(*species_options, name="species", id="species", cls="uk-select"),
|
||||||
id="species",
|
cls="space-y-2",
|
||||||
name="species",
|
|
||||||
),
|
),
|
||||||
# Location dropdown
|
# Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*location_options,
|
FormLabel("Location", _for="location_id"),
|
||||||
label="Location",
|
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||||
id="location_id",
|
cls="space-y-2",
|
||||||
name="location_id",
|
|
||||||
),
|
),
|
||||||
# Count input
|
# Count input
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -239,26 +305,23 @@ def cohort_form(
|
|||||||
value=count_value,
|
value=count_value,
|
||||||
placeholder="Number of animals",
|
placeholder="Number of animals",
|
||||||
),
|
),
|
||||||
# Life stage dropdown
|
# Life stage dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*life_stage_options,
|
FormLabel("Life Stage", _for="life_stage"),
|
||||||
label="Life Stage",
|
Select(*life_stage_options, name="life_stage", id="life_stage", cls="uk-select"),
|
||||||
id="life_stage",
|
cls="space-y-2",
|
||||||
name="life_stage",
|
|
||||||
),
|
),
|
||||||
# Sex dropdown
|
# Sex dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*sex_options,
|
FormLabel("Sex", _for="sex"),
|
||||||
label="Sex",
|
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
|
||||||
id="sex",
|
cls="space-y-2",
|
||||||
name="sex",
|
|
||||||
),
|
),
|
||||||
# Origin dropdown
|
# Origin dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*origin_options,
|
FormLabel("Origin", _for="origin"),
|
||||||
label="Origin",
|
Select(*origin_options, name="origin", id="origin", cls="uk-select"),
|
||||||
id="origin",
|
cls="space-y-2",
|
||||||
name="origin",
|
|
||||||
),
|
),
|
||||||
# Optional notes
|
# Optional notes
|
||||||
LabelTextArea(
|
LabelTextArea(
|
||||||
@@ -272,7 +335,7 @@ def cohort_form(
|
|||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Create Cohort", type="submit", cls=ButtonT.primary),
|
Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -350,19 +413,17 @@ def hatch_form(
|
|||||||
H2("Record Hatch", cls="text-xl font-bold mb-4"),
|
H2("Record Hatch", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
# Species dropdown
|
# Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*species_options,
|
FormLabel("Species", _for="species"),
|
||||||
label="Species",
|
Select(*species_options, name="species", id="species", cls="uk-select"),
|
||||||
id="species",
|
cls="space-y-2",
|
||||||
name="species",
|
|
||||||
),
|
),
|
||||||
# Hatch location dropdown
|
# Hatch location dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*location_options,
|
FormLabel("Hatch Location", _for="location_id"),
|
||||||
label="Hatch Location",
|
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||||
id="location_id",
|
cls="space-y-2",
|
||||||
name="location_id",
|
|
||||||
),
|
),
|
||||||
# Hatched count input
|
# Hatched count input
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -374,13 +435,17 @@ def hatch_form(
|
|||||||
value=hatched_live_value,
|
value=hatched_live_value,
|
||||||
placeholder="Number hatched",
|
placeholder="Number hatched",
|
||||||
),
|
),
|
||||||
# Brood location dropdown (optional)
|
# Brood location dropdown (optional) - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
Div(
|
Div(
|
||||||
LabelSelect(
|
Div(
|
||||||
|
FormLabel("Brood Location (optional)", _for="assigned_brood_location_id"),
|
||||||
|
Select(
|
||||||
*brood_location_options,
|
*brood_location_options,
|
||||||
label="Brood Location (optional)",
|
|
||||||
id="assigned_brood_location_id",
|
|
||||||
name="assigned_brood_location_id",
|
name="assigned_brood_location_id",
|
||||||
|
id="assigned_brood_location_id",
|
||||||
|
cls="uk-select",
|
||||||
|
),
|
||||||
|
cls="space-y-2",
|
||||||
),
|
),
|
||||||
P(
|
P(
|
||||||
"If different from hatch location, hatchlings will be placed here",
|
"If different from hatch location, hatchlings will be placed here",
|
||||||
@@ -399,7 +464,7 @@ def hatch_form(
|
|||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Hatch", type="submit", cls=ButtonT.primary),
|
Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -526,6 +591,7 @@ def promote_form(
|
|||||||
"Save Changes" if is_rename else "Promote to Identified",
|
"Save Changes" if is_rename else "Promote to Identified",
|
||||||
type="submit",
|
type="submit",
|
||||||
cls=ButtonT.primary,
|
cls=ButtonT.primary,
|
||||||
|
hx_disabled_elt="this",
|
||||||
),
|
),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
@@ -652,7 +718,7 @@ def tag_add_form(
|
|||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Add Tag", type="submit", cls=ButtonT.primary),
|
Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -683,60 +749,17 @@ def tag_add_diff_panel(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the diff panel with confirm button.
|
Div containing the diff panel with confirm button.
|
||||||
"""
|
"""
|
||||||
# Build description of changes
|
return diff_confirmation_panel(
|
||||||
changes = []
|
diff=diff,
|
||||||
if diff.removed:
|
filter_str=filter_str,
|
||||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
resolved_ids=resolved_ids,
|
||||||
if diff.added:
|
roster_hash=roster_hash,
|
||||||
changes.append(f"{len(diff.added)} animals were added")
|
ts_utc=ts_utc,
|
||||||
|
|
||||||
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="tag", value=tag),
|
|
||||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
|
||||||
Hidden(name="confirmed", value="true"),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
|
||||||
Div(
|
|
||||||
Button(
|
|
||||||
"Cancel",
|
|
||||||
type="button",
|
|
||||||
cls=ButtonT.default,
|
|
||||||
onclick="window.location.href='/actions/tag-add'",
|
|
||||||
),
|
|
||||||
Button(
|
|
||||||
f"Confirm Tag ({diff.server_count} animals)",
|
|
||||||
type="submit",
|
|
||||||
cls=ButtonT.primary,
|
|
||||||
),
|
|
||||||
cls="flex gap-3 mt-4",
|
|
||||||
),
|
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
action_hidden_fields=[("tag", tag)],
|
||||||
)
|
cancel_url="/actions/tag-add",
|
||||||
|
confirm_button_text=f"Confirm Tag ({diff.server_count} animals)",
|
||||||
return Div(
|
question_text=f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
|
||||||
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 tagging {diff.server_count} animals as '{tag}'?",
|
|
||||||
cls="text-sm",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cls=AlertT.warning,
|
|
||||||
),
|
|
||||||
confirm_form,
|
|
||||||
cls="space-y-4",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -845,12 +868,11 @@ def tag_end_form(
|
|||||||
),
|
),
|
||||||
# Selection container - updated via HTMX when filter changes
|
# Selection container - updated via HTMX when filter changes
|
||||||
selection_container,
|
selection_container,
|
||||||
# Tag dropdown
|
# Tag dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*tag_options,
|
FormLabel("Tag to End", _for="tag"),
|
||||||
label="Tag to End",
|
Select(*tag_options, name="tag", id="tag", cls="uk-select"),
|
||||||
id="tag",
|
cls="space-y-2",
|
||||||
name="tag",
|
|
||||||
)
|
)
|
||||||
if active_tags
|
if active_tags
|
||||||
else Div(
|
else Div(
|
||||||
@@ -872,7 +894,13 @@ def tag_end_form(
|
|||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
|
Button(
|
||||||
|
"End Tag",
|
||||||
|
type="submit",
|
||||||
|
cls=ButtonT.primary,
|
||||||
|
disabled=not active_tags,
|
||||||
|
hx_disabled_elt="this",
|
||||||
|
),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -903,60 +931,17 @@ def tag_end_diff_panel(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the diff panel with confirm button.
|
Div containing the diff panel with confirm button.
|
||||||
"""
|
"""
|
||||||
# Build description of changes
|
return diff_confirmation_panel(
|
||||||
changes = []
|
diff=diff,
|
||||||
if diff.removed:
|
filter_str=filter_str,
|
||||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
resolved_ids=resolved_ids,
|
||||||
if diff.added:
|
roster_hash=roster_hash,
|
||||||
changes.append(f"{len(diff.added)} animals were added")
|
ts_utc=ts_utc,
|
||||||
|
|
||||||
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="tag", value=tag),
|
|
||||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
|
||||||
Hidden(name="confirmed", value="true"),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
|
||||||
Div(
|
|
||||||
Button(
|
|
||||||
"Cancel",
|
|
||||||
type="button",
|
|
||||||
cls=ButtonT.default,
|
|
||||||
onclick="window.location.href='/actions/tag-end'",
|
|
||||||
),
|
|
||||||
Button(
|
|
||||||
f"Confirm End Tag ({diff.server_count} animals)",
|
|
||||||
type="submit",
|
|
||||||
cls=ButtonT.primary,
|
|
||||||
),
|
|
||||||
cls="flex gap-3 mt-4",
|
|
||||||
),
|
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
action_hidden_fields=[("tag", tag)],
|
||||||
)
|
cancel_url="/actions/tag-end",
|
||||||
|
confirm_button_text=f"Confirm End Tag ({diff.server_count} animals)",
|
||||||
return Div(
|
question_text=f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
|
||||||
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 ending tag '{tag}' on {diff.server_count} animals?",
|
|
||||||
cls="text-sm",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cls=AlertT.warning,
|
|
||||||
),
|
|
||||||
confirm_form,
|
|
||||||
cls="space-y-4",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1115,7 +1100,7 @@ def attrs_form(
|
|||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Update Attributes", type="submit", cls=ButtonT.primary),
|
Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -1150,62 +1135,21 @@ def attrs_diff_panel(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the diff panel with confirm button.
|
Div containing the diff panel with confirm button.
|
||||||
"""
|
"""
|
||||||
# Build description of changes
|
return diff_confirmation_panel(
|
||||||
changes = []
|
diff=diff,
|
||||||
if diff.removed:
|
filter_str=filter_str,
|
||||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
resolved_ids=resolved_ids,
|
||||||
if diff.added:
|
roster_hash=roster_hash,
|
||||||
changes.append(f"{len(diff.added)} animals were added")
|
ts_utc=ts_utc,
|
||||||
|
|
||||||
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="sex", value=sex or ""),
|
|
||||||
Hidden(name="life_stage", value=life_stage or ""),
|
|
||||||
Hidden(name="repro_status", value=repro_status or ""),
|
|
||||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
|
||||||
Hidden(name="confirmed", value="true"),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
|
||||||
Div(
|
|
||||||
Button(
|
|
||||||
"Cancel",
|
|
||||||
type="button",
|
|
||||||
cls=ButtonT.default,
|
|
||||||
onclick="window.location.href='/actions/attrs'",
|
|
||||||
),
|
|
||||||
Button(
|
|
||||||
f"Confirm Update ({diff.server_count} animals)",
|
|
||||||
type="submit",
|
|
||||||
cls=ButtonT.primary,
|
|
||||||
),
|
|
||||||
cls="flex gap-3 mt-4",
|
|
||||||
),
|
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
action_hidden_fields=[
|
||||||
)
|
("sex", sex or ""),
|
||||||
|
("life_stage", life_stage or ""),
|
||||||
return Div(
|
("repro_status", repro_status or ""),
|
||||||
Alert(
|
],
|
||||||
Div(
|
cancel_url="/actions/attrs",
|
||||||
P("Selection Changed", cls="font-bold text-lg mb-2"),
|
confirm_button_text=f"Confirm Update ({diff.server_count} animals)",
|
||||||
P(changes_text, cls="mb-2"),
|
question_text=f"Would you like to proceed with updating {diff.server_count} animals?",
|
||||||
P(
|
|
||||||
f"Would you like to proceed with updating {diff.server_count} animals?",
|
|
||||||
cls="text-sm",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cls=AlertT.warning,
|
|
||||||
),
|
|
||||||
confirm_form,
|
|
||||||
cls="space-y-4",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1318,20 +1262,22 @@ def outcome_form(
|
|||||||
yield_section = Div(
|
yield_section = Div(
|
||||||
H3("Yield Items", cls="text-lg font-semibold mt-4 mb-2"),
|
H3("Yield Items", cls="text-lg font-semibold mt-4 mb-2"),
|
||||||
P("Optional: record products collected from harvest", cls="text-sm text-stone-500 mb-3"),
|
P("Optional: record products collected from harvest", cls="text-sm text-stone-500 mb-3"),
|
||||||
|
# Using raw Select due to MonsterUI LabelSelect value bug
|
||||||
Div(
|
Div(
|
||||||
LabelSelect(
|
Div(
|
||||||
|
FormLabel("Product", _for="yield_product_code"),
|
||||||
|
Select(
|
||||||
*product_options,
|
*product_options,
|
||||||
label="Product",
|
|
||||||
id="yield_product_code",
|
|
||||||
name="yield_product_code",
|
name="yield_product_code",
|
||||||
cls="flex-1",
|
id="yield_product_code",
|
||||||
|
cls="uk-select",
|
||||||
),
|
),
|
||||||
LabelSelect(
|
cls="space-y-2 flex-1",
|
||||||
*unit_options,
|
),
|
||||||
label="Unit",
|
Div(
|
||||||
id="yield_unit",
|
FormLabel("Unit", _for="yield_unit"),
|
||||||
name="yield_unit",
|
Select(*unit_options, name="yield_unit", id="yield_unit", cls="uk-select"),
|
||||||
cls="w-32",
|
cls="space-y-2 w-32",
|
||||||
),
|
),
|
||||||
cls="flex gap-3",
|
cls="flex gap-3",
|
||||||
),
|
),
|
||||||
@@ -1377,13 +1323,11 @@ def outcome_form(
|
|||||||
),
|
),
|
||||||
# Selection container - updated via HTMX when filter changes
|
# Selection container - updated via HTMX when filter changes
|
||||||
selection_container,
|
selection_container,
|
||||||
# Outcome selection
|
# Outcome selection - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*outcome_options,
|
FormLabel("Outcome", _for="outcome"),
|
||||||
label="Outcome",
|
Select(*outcome_options, name="outcome", id="outcome", cls="uk-select", required=True),
|
||||||
id="outcome",
|
cls="space-y-2",
|
||||||
name="outcome",
|
|
||||||
required=True,
|
|
||||||
),
|
),
|
||||||
# Reason field
|
# Reason field
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -1410,7 +1354,7 @@ def outcome_form(
|
|||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
|
Button("Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -1451,65 +1395,25 @@ def outcome_diff_panel(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the diff panel with confirm button.
|
Div containing the diff panel with confirm button.
|
||||||
"""
|
"""
|
||||||
# Build description of changes
|
return diff_confirmation_panel(
|
||||||
changes = []
|
diff=diff,
|
||||||
if diff.removed:
|
filter_str=filter_str,
|
||||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
resolved_ids=resolved_ids,
|
||||||
if diff.added:
|
roster_hash=roster_hash,
|
||||||
changes.append(f"{len(diff.added)} animals were added")
|
ts_utc=ts_utc,
|
||||||
|
|
||||||
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="outcome", value=outcome),
|
|
||||||
Hidden(name="reason", value=reason or ""),
|
|
||||||
Hidden(name="yield_product_code", value=yield_product_code or ""),
|
|
||||||
Hidden(name="yield_unit", value=yield_unit or ""),
|
|
||||||
Hidden(name="yield_quantity", value=str(yield_quantity) if yield_quantity else ""),
|
|
||||||
Hidden(name="yield_weight_kg", value=str(yield_weight_kg) if yield_weight_kg else ""),
|
|
||||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
|
||||||
Hidden(name="confirmed", value="true"),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
|
||||||
Div(
|
|
||||||
Button(
|
|
||||||
"Cancel",
|
|
||||||
type="button",
|
|
||||||
cls=ButtonT.default,
|
|
||||||
onclick="window.location.href='/actions/outcome'",
|
|
||||||
),
|
|
||||||
Button(
|
|
||||||
f"Confirm Outcome ({diff.server_count} animals)",
|
|
||||||
type="submit",
|
|
||||||
cls=ButtonT.destructive,
|
|
||||||
),
|
|
||||||
cls="flex gap-3 mt-4",
|
|
||||||
),
|
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
action_hidden_fields=[
|
||||||
)
|
("outcome", outcome),
|
||||||
|
("reason", reason or ""),
|
||||||
return Div(
|
("yield_product_code", yield_product_code or ""),
|
||||||
Alert(
|
("yield_unit", yield_unit or ""),
|
||||||
Div(
|
("yield_quantity", str(yield_quantity) if yield_quantity else ""),
|
||||||
P("Selection Changed", cls="font-bold text-lg mb-2"),
|
("yield_weight_kg", str(yield_weight_kg) if yield_weight_kg else ""),
|
||||||
P(changes_text, cls="mb-2"),
|
],
|
||||||
P(
|
cancel_url="/actions/outcome",
|
||||||
f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
|
confirm_button_text=f"Confirm Outcome ({diff.server_count} animals)",
|
||||||
cls="text-sm",
|
question_text=f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
|
||||||
),
|
confirm_button_cls=ButtonT.destructive,
|
||||||
),
|
|
||||||
cls=AlertT.warning,
|
|
||||||
),
|
|
||||||
confirm_form,
|
|
||||||
cls="space-y-4",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1599,13 +1503,13 @@ def status_correct_form(
|
|||||||
value=filter_str,
|
value=filter_str,
|
||||||
placeholder="e.g., species:duck location:Coop1",
|
placeholder="e.g., species:duck location:Coop1",
|
||||||
),
|
),
|
||||||
# New status selection
|
# New status selection - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*status_options,
|
FormLabel("New Status", _for="new_status"),
|
||||||
label="New Status",
|
Select(
|
||||||
id="new_status",
|
*status_options, name="new_status", id="new_status", cls="uk-select", required=True
|
||||||
name="new_status",
|
),
|
||||||
required=True,
|
cls="space-y-2",
|
||||||
),
|
),
|
||||||
# Reason field (required for admin actions)
|
# Reason field (required for admin actions)
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -1631,7 +1535,7 @@ def status_correct_form(
|
|||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Correct Status", type="submit", cls=ButtonT.destructive),
|
Button("Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -1664,59 +1568,19 @@ def status_correct_diff_panel(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the diff panel with confirm button.
|
Div containing the diff panel with confirm button.
|
||||||
"""
|
"""
|
||||||
# Build description of changes
|
return diff_confirmation_panel(
|
||||||
changes = []
|
diff=diff,
|
||||||
if diff.removed:
|
filter_str=filter_str,
|
||||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
resolved_ids=resolved_ids,
|
||||||
if diff.added:
|
roster_hash=roster_hash,
|
||||||
changes.append(f"{len(diff.added)} animals were added")
|
ts_utc=ts_utc,
|
||||||
|
|
||||||
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="new_status", value=new_status),
|
|
||||||
Hidden(name="reason", value=reason),
|
|
||||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
|
||||||
Hidden(name="confirmed", value="true"),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
|
||||||
Div(
|
|
||||||
Button(
|
|
||||||
"Cancel",
|
|
||||||
type="button",
|
|
||||||
cls=ButtonT.default,
|
|
||||||
onclick="window.location.href='/actions/status-correct'",
|
|
||||||
),
|
|
||||||
Button(
|
|
||||||
f"Confirm Correction ({diff.server_count} animals)",
|
|
||||||
type="submit",
|
|
||||||
cls=ButtonT.destructive,
|
|
||||||
),
|
|
||||||
cls="flex gap-3 mt-4",
|
|
||||||
),
|
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
action_hidden_fields=[
|
||||||
)
|
("new_status", new_status),
|
||||||
|
("reason", reason),
|
||||||
return Div(
|
],
|
||||||
Alert(
|
cancel_url="/actions/status-correct",
|
||||||
Div(
|
confirm_button_text=f"Confirm Correction ({diff.server_count} animals)",
|
||||||
P("Selection Changed", cls="font-bold text-lg mb-2"),
|
question_text=f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
|
||||||
P(changes_text, cls="mb-2"),
|
confirm_button_cls=ButtonT.destructive,
|
||||||
P(
|
|
||||||
f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
|
|
||||||
cls="text-sm",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cls=AlertT.warning,
|
|
||||||
),
|
|
||||||
confirm_form,
|
|
||||||
cls="space-y-4",
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# ABOUTME: Base HTML template for AnimalTrack pages.
|
# ABOUTME: Base HTML template for AnimalTrack pages.
|
||||||
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
|
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
|
||||||
|
|
||||||
from fasthtml.common import Container, Div, Script, Style, Title
|
from fasthtml.common import Container, Div, HttpHeader, Script, Style, Title
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from animaltrack.models.reference import UserRole
|
from animaltrack.models.reference import UserRole
|
||||||
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
|
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
|
||||||
|
from animaltrack.web.templates.shared_scripts import slide_over_script
|
||||||
from animaltrack.web.templates.sidebar import (
|
from animaltrack.web.templates.sidebar import (
|
||||||
MenuDrawer,
|
MenuDrawer,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -79,29 +80,13 @@ def EventSlideOverStyles(): # noqa: N802
|
|||||||
|
|
||||||
def EventSlideOverScript(): # noqa: N802
|
def EventSlideOverScript(): # noqa: N802
|
||||||
"""JavaScript for event slide-over panel open/close behavior."""
|
"""JavaScript for event slide-over panel open/close behavior."""
|
||||||
return Script("""
|
return slide_over_script(
|
||||||
function openEventPanel() {
|
panel_id="event-slide-over",
|
||||||
document.getElementById('event-slide-over').classList.add('open');
|
backdrop_id="event-backdrop",
|
||||||
document.getElementById('event-backdrop').classList.add('open');
|
open_fn_name="openEventPanel",
|
||||||
document.body.style.overflow = 'hidden';
|
close_fn_name="closeEventPanel",
|
||||||
// Focus the panel for keyboard events
|
htmx_auto_open_targets=["event-slide-over", "event-panel-content"],
|
||||||
document.getElementById('event-slide-over').focus();
|
)
|
||||||
}
|
|
||||||
|
|
||||||
function closeEventPanel() {
|
|
||||||
document.getElementById('event-slide-over').classList.remove('open');
|
|
||||||
document.getElementById('event-backdrop').classList.remove('open');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTMX event: after loading event content, open the panel
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
|
||||||
if (evt.detail.target.id === 'event-slide-over' ||
|
|
||||||
evt.detail.target.id === 'event-panel-content') {
|
|
||||||
openEventPanel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def CsrfHeaderScript(): # noqa: N802
|
def CsrfHeaderScript(): # noqa: N802
|
||||||
@@ -157,6 +142,8 @@ def EventSlideOver(): # noqa: N802
|
|||||||
"shadow-2xl border-l border-stone-700 overflow-hidden",
|
"shadow-2xl border-l border-stone-700 overflow-hidden",
|
||||||
tabindex="-1",
|
tabindex="-1",
|
||||||
hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
|
hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
|
||||||
|
role="dialog",
|
||||||
|
aria_label="Event details",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -215,7 +202,7 @@ def page(
|
|||||||
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||||
),
|
),
|
||||||
# Toast container with hx-preserve to survive body swaps for OOB toast injection
|
# Toast container with hx-preserve to survive body swaps for OOB toast injection
|
||||||
Div(id="fh-toast-container", hx_preserve=True),
|
Div(id="fh-toast-container", hx_preserve=True, aria_live="polite"),
|
||||||
# Mobile bottom nav
|
# Mobile bottom nav
|
||||||
BottomNav(active_id=active_nav),
|
BottomNav(active_id=active_nav),
|
||||||
)
|
)
|
||||||
@@ -243,3 +230,26 @@ def render_page(request: Request, content, **page_kwargs):
|
|||||||
user_role=auth.role if auth else None,
|
user_role=auth.role if auth else None,
|
||||||
**page_kwargs,
|
**page_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_post(request: Request, content, push_url: str, **page_kwargs):
|
||||||
|
"""Wrapper for POST responses that sets HX-Push-Url header.
|
||||||
|
|
||||||
|
When HTMX boosted forms submit via POST, the browser URL may not be updated
|
||||||
|
correctly. This wrapper returns the rendered page with an HX-Push-Url header
|
||||||
|
to ensure the browser history shows the correct URL.
|
||||||
|
|
||||||
|
This fixes the issue where window.location.reload() after form submission
|
||||||
|
would reload the wrong URL (the action URL instead of the display URL).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Starlette request object.
|
||||||
|
content: Page content (FT components).
|
||||||
|
push_url: The URL to push to browser history (e.g., '/feed', '/move').
|
||||||
|
**page_kwargs: Additional arguments passed to page() (title, active_nav).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (FT components, HttpHeader) that FastHTML processes together.
|
||||||
|
"""
|
||||||
|
page_content = render_page(request, content, **page_kwargs)
|
||||||
|
return (*page_content, HttpHeader("HX-Push-Url", push_url))
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
|
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Select, Ul
|
||||||
from monsterui.all import (
|
from monsterui.all import (
|
||||||
Button,
|
Button,
|
||||||
ButtonT,
|
ButtonT,
|
||||||
|
FormLabel,
|
||||||
LabelInput,
|
LabelInput,
|
||||||
LabelSelect,
|
|
||||||
LabelTextArea,
|
LabelTextArea,
|
||||||
TabContainer,
|
TabContainer,
|
||||||
)
|
)
|
||||||
@@ -37,6 +37,13 @@ def eggs_page(
|
|||||||
cost_per_egg: float | None = None,
|
cost_per_egg: float | None = None,
|
||||||
sales_stats: dict | None = None,
|
sales_stats: dict | None = None,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
|
# Field value preservation on errors
|
||||||
|
harvest_quantity: str | None = None,
|
||||||
|
harvest_notes: str | None = None,
|
||||||
|
sell_quantity: str | None = None,
|
||||||
|
sell_total_price_cents: str | None = None,
|
||||||
|
sell_buyer: str | None = None,
|
||||||
|
sell_notes: str | None = None,
|
||||||
):
|
):
|
||||||
"""Create the Eggs page with tabbed forms.
|
"""Create the Eggs page with tabbed forms.
|
||||||
|
|
||||||
@@ -56,6 +63,12 @@ def eggs_page(
|
|||||||
cost_per_egg: 30-day average cost per egg in EUR.
|
cost_per_egg: 30-day average cost per egg in EUR.
|
||||||
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'.
|
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'.
|
||||||
location_names: Dict mapping location_id to location name for display.
|
location_names: Dict mapping location_id to location name for display.
|
||||||
|
harvest_quantity: Preserved quantity value on error.
|
||||||
|
harvest_notes: Preserved notes value on error.
|
||||||
|
sell_quantity: Preserved quantity value on error.
|
||||||
|
sell_total_price_cents: Preserved total price value on error.
|
||||||
|
sell_buyer: Preserved buyer value on error.
|
||||||
|
sell_notes: Preserved notes value on error.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Page content with tabbed forms.
|
Page content with tabbed forms.
|
||||||
@@ -85,6 +98,8 @@ def eggs_page(
|
|||||||
eggs_per_day=eggs_per_day,
|
eggs_per_day=eggs_per_day,
|
||||||
cost_per_egg=cost_per_egg,
|
cost_per_egg=cost_per_egg,
|
||||||
location_names=location_names,
|
location_names=location_names,
|
||||||
|
default_quantity=harvest_quantity,
|
||||||
|
default_notes=harvest_notes,
|
||||||
),
|
),
|
||||||
cls="uk-active" if harvest_active else None,
|
cls="uk-active" if harvest_active else None,
|
||||||
),
|
),
|
||||||
@@ -96,6 +111,10 @@ def eggs_page(
|
|||||||
action=sell_action,
|
action=sell_action,
|
||||||
recent_events=sell_events,
|
recent_events=sell_events,
|
||||||
sales_stats=sales_stats,
|
sales_stats=sales_stats,
|
||||||
|
default_quantity=sell_quantity,
|
||||||
|
default_total_price_cents=sell_total_price_cents,
|
||||||
|
default_buyer=sell_buyer,
|
||||||
|
default_notes=sell_notes,
|
||||||
),
|
),
|
||||||
cls=None if harvest_active else "uk-active",
|
cls=None if harvest_active else "uk-active",
|
||||||
),
|
),
|
||||||
@@ -113,6 +132,8 @@ def harvest_form(
|
|||||||
eggs_per_day: float | None = None,
|
eggs_per_day: float | None = None,
|
||||||
cost_per_egg: float | None = None,
|
cost_per_egg: float | None = None,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
|
default_quantity: str | None = None,
|
||||||
|
default_notes: str | None = None,
|
||||||
) -> Div:
|
) -> Div:
|
||||||
"""Create the Harvest form for egg collection.
|
"""Create the Harvest form for egg collection.
|
||||||
|
|
||||||
@@ -125,6 +146,8 @@ def harvest_form(
|
|||||||
eggs_per_day: 30-day average eggs per day.
|
eggs_per_day: 30-day average eggs per day.
|
||||||
cost_per_egg: 30-day average cost per egg in EUR.
|
cost_per_egg: 30-day average cost per egg in EUR.
|
||||||
location_names: Dict mapping location_id to location name for display.
|
location_names: Dict mapping location_id to location name for display.
|
||||||
|
default_quantity: Preserved quantity value on error.
|
||||||
|
default_notes: Preserved notes value on error.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Div containing form and recent events section.
|
Div containing form and recent events section.
|
||||||
@@ -177,12 +200,11 @@ def harvest_form(
|
|||||||
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
# Location dropdown
|
# Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*location_options,
|
FormLabel("Location", _for="location_id"),
|
||||||
label="Location",
|
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||||
id="location_id",
|
cls="space-y-2",
|
||||||
name="location_id",
|
|
||||||
),
|
),
|
||||||
# Quantity input (integer only, 0 allowed for "checked but found none")
|
# Quantity input (integer only, 0 allowed for "checked but found none")
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -194,6 +216,7 @@ def harvest_form(
|
|||||||
step="1",
|
step="1",
|
||||||
placeholder="Number of eggs",
|
placeholder="Number of eggs",
|
||||||
required=True,
|
required=True,
|
||||||
|
value=default_quantity or "",
|
||||||
),
|
),
|
||||||
# Optional notes
|
# Optional notes
|
||||||
LabelTextArea(
|
LabelTextArea(
|
||||||
@@ -201,13 +224,14 @@ def harvest_form(
|
|||||||
id="notes",
|
id="notes",
|
||||||
name="notes",
|
name="notes",
|
||||||
placeholder="Optional notes",
|
placeholder="Optional notes",
|
||||||
|
value=default_notes or "",
|
||||||
),
|
),
|
||||||
# Event datetime picker (for backdating)
|
# Event datetime picker (for backdating)
|
||||||
event_datetime_field("harvest_datetime"),
|
event_datetime_field("harvest_datetime"),
|
||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Harvest", type="submit", cls=ButtonT.primary),
|
Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -232,6 +256,10 @@ def sell_form(
|
|||||||
action: Callable[..., Any] | str = "/actions/product-sold",
|
action: Callable[..., Any] | str = "/actions/product-sold",
|
||||||
recent_events: list[tuple[Event, bool]] | None = None,
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
sales_stats: dict | None = None,
|
sales_stats: dict | None = None,
|
||||||
|
default_quantity: str | None = None,
|
||||||
|
default_total_price_cents: str | None = None,
|
||||||
|
default_buyer: str | None = None,
|
||||||
|
default_notes: str | None = None,
|
||||||
) -> Div:
|
) -> Div:
|
||||||
"""Create the Sell form for recording sales.
|
"""Create the Sell form for recording sales.
|
||||||
|
|
||||||
@@ -242,6 +270,10 @@ def sell_form(
|
|||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
|
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
|
||||||
|
default_quantity: Preserved quantity value on error.
|
||||||
|
default_total_price_cents: Preserved total price value on error.
|
||||||
|
default_buyer: Preserved buyer value on error.
|
||||||
|
default_notes: Preserved notes value on error.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Div containing form and recent events section.
|
Div containing form and recent events section.
|
||||||
@@ -300,12 +332,11 @@ def sell_form(
|
|||||||
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
# Product dropdown
|
# Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*product_options,
|
FormLabel("Product", _for="product_code"),
|
||||||
label="Product",
|
Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
|
||||||
id="product_code",
|
cls="space-y-2",
|
||||||
name="product_code",
|
|
||||||
),
|
),
|
||||||
# Quantity input (integer only, min=1)
|
# Quantity input (integer only, min=1)
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -317,6 +348,7 @@ def sell_form(
|
|||||||
step="1",
|
step="1",
|
||||||
placeholder="Number of items sold",
|
placeholder="Number of items sold",
|
||||||
required=True,
|
required=True,
|
||||||
|
value=default_quantity or "",
|
||||||
),
|
),
|
||||||
# Total price in cents
|
# Total price in cents
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -328,6 +360,7 @@ def sell_form(
|
|||||||
step="1",
|
step="1",
|
||||||
placeholder="Total price in cents",
|
placeholder="Total price in cents",
|
||||||
required=True,
|
required=True,
|
||||||
|
value=default_total_price_cents or "",
|
||||||
),
|
),
|
||||||
# Optional buyer
|
# Optional buyer
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -336,6 +369,7 @@ def sell_form(
|
|||||||
name="buyer",
|
name="buyer",
|
||||||
type="text",
|
type="text",
|
||||||
placeholder="Optional buyer name",
|
placeholder="Optional buyer name",
|
||||||
|
value=default_buyer or "",
|
||||||
),
|
),
|
||||||
# Optional notes
|
# Optional notes
|
||||||
LabelTextArea(
|
LabelTextArea(
|
||||||
@@ -343,13 +377,14 @@ def sell_form(
|
|||||||
id="sell_notes",
|
id="sell_notes",
|
||||||
name="notes",
|
name="notes",
|
||||||
placeholder="Optional notes",
|
placeholder="Optional notes",
|
||||||
|
value=default_notes or "",
|
||||||
),
|
),
|
||||||
# Event datetime picker (for backdating)
|
# Event datetime picker (for backdating)
|
||||||
event_datetime_field("sell_datetime"),
|
event_datetime_field("sell_datetime"),
|
||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Sale", type="submit", cls=ButtonT.primary),
|
Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ def give_feed_form(
|
|||||||
# Hidden nonce
|
# Hidden nonce
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Feed Given", type="submit", cls=ButtonT.primary),
|
Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
@@ -404,7 +404,7 @@ def purchase_feed_form(
|
|||||||
# Hidden nonce
|
# Hidden nonce
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Purchase", type="submit", cls=ButtonT.primary),
|
Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def location_list(
|
|||||||
placeholder="Enter location name",
|
placeholder="Enter location name",
|
||||||
),
|
),
|
||||||
Hidden(name="nonce", value=str(uuid4())),
|
Hidden(name="nonce", value=str(uuid4())),
|
||||||
Button("Create Location", type="submit", cls=ButtonT.primary),
|
Button("Create Location", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
hx_post="/actions/location-created",
|
hx_post="/actions/location-created",
|
||||||
hx_target="#location-list",
|
hx_target="#location-list",
|
||||||
hx_swap="outerHTML",
|
hx_swap="outerHTML",
|
||||||
@@ -160,7 +160,7 @@ def rename_form(
|
|||||||
Hidden(name="nonce", value=str(uuid4())),
|
Hidden(name="nonce", value=str(uuid4())),
|
||||||
DivFullySpaced(
|
DivFullySpaced(
|
||||||
Button("Cancel", type="button", cls=ButtonT.ghost, hx_get="/locations"),
|
Button("Cancel", type="button", cls=ButtonT.ghost, hx_get="/locations"),
|
||||||
Button("Rename", type="submit", cls=ButtonT.primary),
|
Button("Rename", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
),
|
),
|
||||||
hx_post="/actions/location-renamed",
|
hx_post="/actions/location-renamed",
|
||||||
hx_target="#location-list",
|
hx_target="#location-list",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
|
from fasthtml.common import H2, A, Div, Form, Hidden, Option, P, Select, Span
|
||||||
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
from monsterui.all import Alert, AlertT, Button, ButtonT, FormLabel, LabelInput, LabelTextArea
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
@@ -151,12 +151,11 @@ def move_form(
|
|||||||
),
|
),
|
||||||
# Selection container - updated via HTMX when filter changes
|
# Selection container - updated via HTMX when filter changes
|
||||||
selection_container,
|
selection_container,
|
||||||
# Destination dropdown
|
# Destination dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*location_options,
|
FormLabel("Destination", _for="to_location_id"),
|
||||||
label="Destination",
|
Select(*location_options, name="to_location_id", id="to_location_id", cls="uk-select"),
|
||||||
id="to_location_id",
|
cls="space-y-2",
|
||||||
name="to_location_id",
|
|
||||||
),
|
),
|
||||||
# Optional notes
|
# Optional notes
|
||||||
LabelTextArea(
|
LabelTextArea(
|
||||||
@@ -175,7 +174,7 @@ def move_form(
|
|||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Move Animals", type="submit", cls=ButtonT.primary),
|
Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
@@ -254,16 +253,16 @@ def diff_panel(
|
|||||||
Hidden(name="confirmed", value="true"),
|
Hidden(name="confirmed", value="true"),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
Div(
|
Div(
|
||||||
Button(
|
A(
|
||||||
"Cancel",
|
"Cancel",
|
||||||
type="button",
|
href="/move",
|
||||||
cls=ButtonT.default,
|
cls=ButtonT.default,
|
||||||
onclick="window.location.href='/move'",
|
|
||||||
),
|
),
|
||||||
Button(
|
Button(
|
||||||
f"Confirm Move ({diff.server_count} animals)",
|
f"Confirm Move ({diff.server_count} animals)",
|
||||||
type="submit",
|
type="submit",
|
||||||
cls=ButtonT.primary,
|
cls=ButtonT.primary,
|
||||||
|
hx_disabled_elt="this",
|
||||||
),
|
),
|
||||||
cls="flex gap-3 mt-4",
|
cls="flex gap-3 mt-4",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ def BottomNav(active_id: str = "eggs"): # noqa: N802
|
|||||||
onclick="openMenuDrawer()",
|
onclick="openMenuDrawer()",
|
||||||
cls=wrapper_cls,
|
cls=wrapper_cls,
|
||||||
type="button",
|
type="button",
|
||||||
|
aria_label="Open navigation menu",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular nav items are links
|
# Regular nav items are links
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H2, Form, Hidden, Option
|
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Select
|
||||||
from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
from monsterui.all import Button, ButtonT, FormLabel, LabelInput, LabelTextArea
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
from animaltrack.models.reference import Product
|
from animaltrack.models.reference import Product
|
||||||
@@ -47,8 +47,6 @@ def product_sold_form(
|
|||||||
# Error display component
|
# Error display component
|
||||||
error_component = None
|
error_component = None
|
||||||
if error:
|
if error:
|
||||||
from fasthtml.common import Div, P
|
|
||||||
|
|
||||||
error_component = Div(
|
error_component = Div(
|
||||||
P(error, cls="text-red-500 text-sm"),
|
P(error, cls="text-red-500 text-sm"),
|
||||||
cls="mb-4",
|
cls="mb-4",
|
||||||
@@ -58,12 +56,11 @@ def product_sold_form(
|
|||||||
H2("Record Sale", cls="text-xl font-bold mb-4"),
|
H2("Record Sale", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
# Product dropdown
|
# Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||||
LabelSelect(
|
Div(
|
||||||
*product_options,
|
FormLabel("Product", _for="product_code"),
|
||||||
label="Product",
|
Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
|
||||||
id="product_code",
|
cls="space-y-2",
|
||||||
name="product_code",
|
|
||||||
),
|
),
|
||||||
# Quantity input (integer only, min=1)
|
# Quantity input (integer only, min=1)
|
||||||
LabelInput(
|
LabelInput(
|
||||||
@@ -105,7 +102,7 @@ def product_sold_form(
|
|||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Sale", type="submit", cls=ButtonT.primary),
|
Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action=action,
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
|
|||||||
@@ -107,7 +107,12 @@ def registry_header(filter_str: str, total_count: int) -> Div:
|
|||||||
),
|
),
|
||||||
# Buttons container
|
# Buttons container
|
||||||
Div(
|
Div(
|
||||||
Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"),
|
Button(
|
||||||
|
"Apply",
|
||||||
|
type="submit",
|
||||||
|
cls=f"{ButtonT.primary} px-4",
|
||||||
|
hx_disabled_elt="this",
|
||||||
|
),
|
||||||
# Clear button (only shown if filter is active)
|
# Clear button (only shown if filter is active)
|
||||||
A(
|
A(
|
||||||
"Clear",
|
"Clear",
|
||||||
|
|||||||
58
src/animaltrack/web/templates/shared_scripts.py
Normal file
58
src/animaltrack/web/templates/shared_scripts.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ABOUTME: Shared JavaScript script generators for AnimalTrack templates.
|
||||||
|
# ABOUTME: Provides reusable script components to reduce code duplication.
|
||||||
|
|
||||||
|
from fasthtml.common import Script
|
||||||
|
|
||||||
|
|
||||||
|
def slide_over_script(
|
||||||
|
panel_id: str,
|
||||||
|
backdrop_id: str,
|
||||||
|
open_fn_name: str,
|
||||||
|
close_fn_name: str,
|
||||||
|
htmx_auto_open_targets: list[str] | None = None,
|
||||||
|
) -> Script:
|
||||||
|
"""Generate JavaScript for slide-over panel open/close behavior.
|
||||||
|
|
||||||
|
Creates global functions for opening and closing a slide-over panel with
|
||||||
|
backdrop. Optionally auto-opens when HTMX swaps content into specified targets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
panel_id: DOM ID of the slide-over panel element.
|
||||||
|
backdrop_id: DOM ID of the backdrop overlay element.
|
||||||
|
open_fn_name: Name of the global function to open the panel.
|
||||||
|
close_fn_name: Name of the global function to close the panel.
|
||||||
|
htmx_auto_open_targets: List of target element IDs that trigger auto-open
|
||||||
|
when HTMX swaps content into them.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Script element containing the JavaScript code.
|
||||||
|
"""
|
||||||
|
# Build HTMX auto-open listener if targets specified
|
||||||
|
htmx_listener = ""
|
||||||
|
if htmx_auto_open_targets:
|
||||||
|
conditions = " ||\n ".join(
|
||||||
|
f"evt.detail.target.id === '{target}'" for target in htmx_auto_open_targets
|
||||||
|
)
|
||||||
|
htmx_listener = f"""
|
||||||
|
// HTMX event: after loading content, open the panel
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {{
|
||||||
|
if ({conditions}) {{
|
||||||
|
{open_fn_name}();
|
||||||
|
}}
|
||||||
|
}});"""
|
||||||
|
|
||||||
|
return Script(f"""
|
||||||
|
function {open_fn_name}() {{
|
||||||
|
document.getElementById('{panel_id}').classList.add('open');
|
||||||
|
document.getElementById('{backdrop_id}').classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
// Focus the panel for keyboard events
|
||||||
|
document.getElementById('{panel_id}').focus();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function {close_fn_name}() {{
|
||||||
|
document.getElementById('{panel_id}').classList.remove('open');
|
||||||
|
document.getElementById('{backdrop_id}').classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}}{htmx_listener}
|
||||||
|
""")
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
# ABOUTME: Responsive sidebar and menu drawer components for AnimalTrack.
|
# ABOUTME: Responsive sidebar and menu drawer components for AnimalTrack.
|
||||||
# ABOUTME: Desktop shows persistent sidebar, mobile shows slide-out drawer.
|
# ABOUTME: Desktop shows persistent sidebar, mobile shows slide-out drawer.
|
||||||
|
|
||||||
from fasthtml.common import A, Button, Div, Nav, Script, Span, Style
|
from fasthtml.common import A, Button, Div, Nav, Span, Style
|
||||||
from fasthtml.svg import Path, Svg
|
from fasthtml.svg import Path, Svg
|
||||||
|
|
||||||
from animaltrack.build_info import get_build_info
|
from animaltrack.build_info import get_build_info
|
||||||
from animaltrack.models.reference import UserRole
|
from animaltrack.models.reference import UserRole
|
||||||
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
|
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
|
||||||
|
from animaltrack.web.templates.shared_scripts import slide_over_script
|
||||||
|
|
||||||
|
|
||||||
def SidebarStyles(): # noqa: N802
|
def SidebarStyles(): # noqa: N802
|
||||||
@@ -73,21 +74,12 @@ def SidebarStyles(): # noqa: N802
|
|||||||
|
|
||||||
def SidebarScript(): # noqa: N802
|
def SidebarScript(): # noqa: N802
|
||||||
"""JavaScript for menu drawer open/close behavior."""
|
"""JavaScript for menu drawer open/close behavior."""
|
||||||
return Script("""
|
return slide_over_script(
|
||||||
function openMenuDrawer() {
|
panel_id="menu-drawer",
|
||||||
document.getElementById('menu-drawer').classList.add('open');
|
backdrop_id="menu-backdrop",
|
||||||
document.getElementById('menu-backdrop').classList.add('open');
|
open_fn_name="openMenuDrawer",
|
||||||
document.body.style.overflow = 'hidden';
|
close_fn_name="closeMenuDrawer",
|
||||||
// Focus the drawer for keyboard events
|
)
|
||||||
document.getElementById('menu-drawer').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMenuDrawer() {
|
|
||||||
document.getElementById('menu-drawer').classList.remove('open');
|
|
||||||
document.getElementById('menu-backdrop').classList.remove('open');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def _primary_nav_item(label: str, href: str, icon_fn, is_active: bool):
|
def _primary_nav_item(label: str, href: str, icon_fn, is_active: bool):
|
||||||
@@ -264,6 +256,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
|||||||
hx_on_click="closeMenuDrawer()",
|
hx_on_click="closeMenuDrawer()",
|
||||||
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
||||||
type="button",
|
type="button",
|
||||||
|
aria_label="Close menu",
|
||||||
),
|
),
|
||||||
cls="flex items-center justify-between px-4 py-4 border-b border-stone-800",
|
cls="flex items-center justify-between px-4 py-4 border-b border-stone-800",
|
||||||
),
|
),
|
||||||
@@ -276,6 +269,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
|||||||
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
|
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
|
||||||
tabindex="-1",
|
tabindex="-1",
|
||||||
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
|
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
|
||||||
|
role="dialog",
|
||||||
|
aria_label="Navigation menu",
|
||||||
),
|
),
|
||||||
cls="md:hidden",
|
cls="md:hidden",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user