Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m33s
Continues the pattern of eliminating Python sx_call tree-building in favour of data-driven .sx defcomps. POST/PUT/DELETE routes now pass plain data (dicts, lists, scalars) and let .sx handle iteration, conditionals, and layout via map/let/when/if. Single response components wrap OOB swaps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
324 lines
15 KiB
Python
324 lines
15 KiB
Python
"""Slot panels, forms, edit/add, slot picker JS."""
|
|
from __future__ import annotations
|
|
|
|
|
|
from shared.sx.helpers import sx_call
|
|
|
|
|
|
# ===========================================================================
|
|
# SLOT PICKER JS — shared by entry edit + entry add forms
|
|
# ===========================================================================
|
|
|
|
_SLOT_PICKER_JS = """\
|
|
<script>
|
|
(function () {
|
|
function timeToMinutes(timeStr) {
|
|
if (!timeStr) return 0;
|
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
function calculateCost(slotCost, slotStart, slotEnd, actualStart, actualEnd, flexible) {
|
|
if (!flexible) {
|
|
return parseFloat(slotCost);
|
|
}
|
|
if (!actualStart || !actualEnd) return 0;
|
|
const slotStartMin = timeToMinutes(slotStart);
|
|
const slotEndMin = timeToMinutes(slotEnd);
|
|
const actualStartMin = timeToMinutes(actualStart);
|
|
const actualEndMin = timeToMinutes(actualEnd);
|
|
const slotDuration = slotEndMin - slotStartMin;
|
|
const actualDuration = actualEndMin - actualStartMin;
|
|
if (slotDuration <= 0 || actualDuration <= 0) return 0;
|
|
const ratio = actualDuration / slotDuration;
|
|
return parseFloat(slotCost) * ratio;
|
|
}
|
|
|
|
function initEntrySlotPicker(root, applyInitial) {
|
|
if (applyInitial === undefined) applyInitial = false;
|
|
const select = root.querySelector('[data-slot-picker]');
|
|
if (!select) return;
|
|
const timeFields = root.querySelector('[data-time-fields]');
|
|
const startInput = root.querySelector('[data-entry-start]');
|
|
const endInput = root.querySelector('[data-entry-end]');
|
|
const helper = root.querySelector('[data-slot-boundary]');
|
|
const costDisplay = root.querySelector('[data-cost-display]');
|
|
const costRow = root.querySelector('[data-cost-row]');
|
|
const fixedSummary = root.querySelector('[data-fixed-summary]');
|
|
if (!startInput || !endInput) return;
|
|
|
|
function updateCost() {
|
|
const opt = select.selectedOptions[0];
|
|
if (!opt || !opt.value) {
|
|
if (costDisplay) costDisplay.textContent = '\\u00a30.00';
|
|
return;
|
|
}
|
|
const cost = opt.dataset.cost || '0';
|
|
const s = opt.dataset.start || '';
|
|
const e = opt.dataset.end || '';
|
|
const flexible = opt.dataset.flexible === '1';
|
|
const calculatedCost = calculateCost(cost, s, e, startInput.value, endInput.value, flexible);
|
|
if (costDisplay) costDisplay.textContent = '\\u00a3' + calculatedCost.toFixed(2);
|
|
}
|
|
|
|
function applyFromOption(opt) {
|
|
if (!opt || !opt.value) {
|
|
if (timeFields) timeFields.classList.add('hidden');
|
|
if (costRow) costRow.classList.add('hidden');
|
|
if (fixedSummary) fixedSummary.classList.add('hidden');
|
|
return;
|
|
}
|
|
const s = opt.dataset.start || '';
|
|
const e = opt.dataset.end || '';
|
|
const flexible = opt.dataset.flexible === '1';
|
|
if (!flexible) {
|
|
if (s) startInput.value = s;
|
|
if (e) endInput.value = e;
|
|
if (timeFields) timeFields.classList.add('hidden');
|
|
if (fixedSummary) {
|
|
fixedSummary.classList.remove('hidden');
|
|
fixedSummary.textContent = e ? s + ' \\u2013 ' + e : 'From ' + s + ' (open-ended)';
|
|
}
|
|
if (costRow) costRow.classList.remove('hidden');
|
|
} else {
|
|
if (timeFields) timeFields.classList.remove('hidden');
|
|
if (fixedSummary) fixedSummary.classList.add('hidden');
|
|
if (costRow) costRow.classList.remove('hidden');
|
|
if (helper) {
|
|
helper.textContent = e ? 'Times must be between ' + s + ' and ' + e + '.' : 'Start at or after ' + s + '.';
|
|
}
|
|
}
|
|
updateCost();
|
|
}
|
|
|
|
if (applyInitial) applyFromOption(select.selectedOptions[0]);
|
|
|
|
if (select._slotChangeHandler) select.removeEventListener('change', select._slotChangeHandler);
|
|
select._slotChangeHandler = () => applyFromOption(select.selectedOptions[0]);
|
|
select.addEventListener('change', select._slotChangeHandler);
|
|
startInput.addEventListener('input', updateCost);
|
|
endInput.addEventListener('input', updateCost);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => initEntrySlotPicker(document, true));
|
|
if (window.htmx) htmx.onLoad((content) => initEntrySlotPicker(content, true));
|
|
})();
|
|
</script>"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot options (shared by entry edit + add forms)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _slot_options_data(day_slots, selected_slot_id=None) -> list:
|
|
"""Extract slot option data for sx composition."""
|
|
result = []
|
|
for slot in day_slots:
|
|
start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
|
end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
|
flexible = getattr(slot, "flexible", False)
|
|
cost = getattr(slot, "cost", None)
|
|
cost_str = str(cost) if cost is not None else "0"
|
|
label_parts = [slot.name, f"({start}"]
|
|
if end:
|
|
label_parts.append(f"\u2013{end})")
|
|
else:
|
|
label_parts.append("\u2013open-ended)")
|
|
if flexible:
|
|
label_parts.append("[flexible]")
|
|
result.append({
|
|
"value": str(slot.id),
|
|
"data-start": start, "data-end": end,
|
|
"data-flexible": "1" if flexible else "0",
|
|
"data-cost": cost_str,
|
|
"selected": "selected" if selected_slot_id == slot.id else None,
|
|
"label": " ".join(label_parts),
|
|
})
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot header row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
|
|
"""Build the slot detail header row."""
|
|
from quart import url_for
|
|
|
|
calendar = ctx.get("calendar")
|
|
if not calendar:
|
|
return ""
|
|
cal_slug = getattr(calendar, "slug", "")
|
|
slot = ctx.get("slot")
|
|
if not slot:
|
|
return ""
|
|
|
|
desc = getattr(slot, "description", "") or ""
|
|
label_sx = sx_call("events-slot-label",
|
|
name=slot.name, description=desc)
|
|
|
|
return sx_call("menu-row-sx", id="slot-row", level=5,
|
|
link_label_content=label_sx,
|
|
child_id="slot-header-child", oob=oob)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot main panel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
|
"""Render slot detail view via data extraction + sx defcomp."""
|
|
from quart import url_for, g
|
|
|
|
styles = getattr(g, "styles", None) or {}
|
|
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
|
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
|
cal_slug = getattr(calendar, "slug", "")
|
|
|
|
days_display = getattr(slot, "days_display", "\u2014")
|
|
days = days_display.split(", ")
|
|
if days and days[0] == "\u2014":
|
|
days = []
|
|
|
|
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
|
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
|
cost = getattr(slot, "cost", None)
|
|
|
|
return sx_call("events-slot-panel-from-data",
|
|
slot_id=str(slot.id), list_container=list_container,
|
|
days=days or None,
|
|
flexible="yes" if getattr(slot, "flexible", False) else "no",
|
|
time_str=f"{time_start} \u2014 {time_end}",
|
|
cost_str=f"{cost:.2f}" if cost is not None else "",
|
|
pre_action=pre_action,
|
|
edit_url=url_for("calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug),
|
|
description=getattr(slot, "description", "") or "",
|
|
oob=oob or None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slots table
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_slots_table(slots, calendar) -> str:
|
|
"""Render slots table via data extraction + sx defcomp."""
|
|
from quart import url_for, g
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
csrf = generate_csrf_token()
|
|
|
|
styles = getattr(g, "styles", None) or {}
|
|
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
|
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
|
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
|
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
|
|
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
|
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
|
cal_slug = getattr(calendar, "slug", "")
|
|
|
|
slots_data = []
|
|
if slots:
|
|
for s in slots:
|
|
days_display = getattr(s, "days_display", "\u2014")
|
|
day_list = days_display.split(", ")
|
|
if day_list and day_list[0] == "\u2014":
|
|
day_list = []
|
|
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
|
|
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
|
|
cost = getattr(s, "cost", None)
|
|
slots_data.append({
|
|
"slot-href": url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id),
|
|
"slot-name": s.name,
|
|
"description": getattr(s, "description", "") or "",
|
|
"flexible": "yes" if s.flexible else "no",
|
|
"days": day_list or None,
|
|
"time-str": f"{time_start} - {time_end}",
|
|
"cost-str": f"{cost:.2f}" if cost is not None else "",
|
|
"del-url": url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id),
|
|
})
|
|
|
|
return sx_call("events-slots-table-from-data",
|
|
list_container=list_container, slots=slots_data or None,
|
|
pre_action=pre_action,
|
|
add_url=url_for("calendar.slots.add_form", calendar_slug=cal_slug),
|
|
tr_cls=tr_cls, pill_cls=pill_cls, action_btn=action_btn,
|
|
hx_select=hx_select,
|
|
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot edit form
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_slot_edit_form(slot, calendar) -> str:
|
|
"""Render slot edit form via data extraction + sx defcomp."""
|
|
from quart import url_for, g
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
csrf = generate_csrf_token()
|
|
|
|
styles = getattr(g, "styles", None) or {}
|
|
list_container = styles.get("list_container", "") if isinstance(styles, dict) else getattr(styles, "list_container", "")
|
|
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
|
|
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
|
|
cal_slug = getattr(calendar, "slug", "")
|
|
sid = slot.id
|
|
|
|
cost = getattr(slot, "cost", None)
|
|
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
|
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
|
|
days_data = [{"name": k, "label": lbl, "checked": getattr(slot, k, False) or None}
|
|
for k, lbl in day_keys]
|
|
all_checked = all(getattr(slot, k, False) for k, _ in day_keys)
|
|
|
|
return sx_call("events-slot-edit-form-from-data",
|
|
slot_id=str(sid), list_container=list_container,
|
|
put_url=url_for("calendar.slots.slot.put", calendar_slug=cal_slug, slot_id=sid),
|
|
cancel_url=url_for("calendar.slots.slot.get_view", calendar_slug=cal_slug, slot_id=sid),
|
|
csrf=csrf,
|
|
name_val=slot.name or "",
|
|
cost_val=f"{cost:.2f}" if cost is not None else "",
|
|
start_val=slot.time_start.strftime("%H:%M") if slot.time_start else "",
|
|
end_val=slot.time_end.strftime("%H:%M") if slot.time_end else "",
|
|
desc_val=getattr(slot, "description", "") or "",
|
|
days_data=days_data, all_checked=all_checked or None,
|
|
flexible_checked="checked" if getattr(slot, "flexible", False) else None,
|
|
action_btn=action_btn, cancel_btn=cancel_btn)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot add form / button
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_slot_add_form(calendar) -> str:
|
|
"""Render slot add form via data extraction + sx defcomp."""
|
|
from quart import url_for, g
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
csrf = generate_csrf_token()
|
|
|
|
styles = getattr(g, "styles", None) or {}
|
|
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
|
|
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
|
|
cal_slug = getattr(calendar, "slug", "")
|
|
|
|
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
|
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
|
|
days_data = [{"name": k, "label": lbl} for k, lbl in day_keys]
|
|
|
|
return sx_call("events-slot-add-form-from-data",
|
|
post_url=url_for("calendar.slots.post", calendar_slug=cal_slug),
|
|
csrf=f'{{"X-CSRFToken": "{csrf}"}}',
|
|
days_data=days_data,
|
|
action_btn=action_btn, cancel_btn=cancel_btn,
|
|
cancel_url=url_for("calendar.slots.add_button", calendar_slug=cal_slug))
|
|
|
|
|
|
def render_slot_add_button(calendar) -> str:
|
|
"""Render slot add button (replaces _types/slots/_add_button.html)."""
|
|
from quart import url_for, g
|
|
|
|
styles = getattr(g, "styles", None) or {}
|
|
pre_action = styles.get("pre_action_button", "") if isinstance(styles, dict) else getattr(styles, "pre_action_button", "")
|
|
cal_slug = getattr(calendar, "slug", "")
|
|
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
|
|
|
|
return sx_call("events-slot-add-button", pre_action=pre_action, add_url=add_url)
|