// ============ TaskQuest — shared widgets ============ const { useState, useEffect, useRef, useMemo, useCallback } = React; function Avatar({ who, size = "", className = "", style = {} }) { const r = window.TQ.ROLES[who]; if (!r) return null; return (
{r.initials}
); } // click-outside hook function useClickOutside(ref, onClose, active) { useEffect(() => { if (!active) return; function handler(e) { if (ref.current && !ref.current.contains(e.target)) onClose(); } function esc(e) { if (e.key === "Escape") onClose(); } document.addEventListener("mousedown", handler); document.addEventListener("keydown", esc); return () => { document.removeEventListener("mousedown", handler); document.removeEventListener("keydown", esc); }; }, [active]); } // Calendar picker — restricted to today+ by default function Calendar({ value, onChange, min }) { const { TODAY, parseISO } = window.TQ; const init = value ? parseISO(value) : new Date(TODAY); const [view, setView] = useState(new Date(init.getFullYear(), init.getMonth(), 1)); const minDate = min ? parseISO(min) : null; const year = view.getFullYear(), month = view.getMonth(); const first = new Date(year, month, 1); const startDow = first.getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const cells = []; for (let i = 0; i < startDow; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); function iso(d) { const m = String(month + 1).padStart(2, "0"), dd = String(d).padStart(2, "0"); return `${year}-${m}-${dd}`; } const todayIso = `${TODAY.getFullYear()}-${String(TODAY.getMonth()+1).padStart(2,"0")}-${String(TODAY.getDate()).padStart(2,"0")}`; return (
{view.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
{["S","M","T","W","T","F","S"].map((d, i) =>
{d}
)} {cells.map((d, i) => { if (d === null) return
; const cellIso = iso(d); const cellDate = new Date(year, month, d); const disabled = minDate && cellDate < minDate; const sel = value === cellIso; const isToday = cellIso === todayIso; return (
!disabled && onChange(cellIso)}> {d}
); })}
); } // Searchable single-select — type to filter a long list of people/options. // options: array of keys; getLabel(key)->string; renderOption(key)->JSX node. function SearchableSelect({ options, value, onChange, getLabel, renderOption, placeholder = "Search…", emptyText = "No matches" }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const ref = useRef(null); useClickOutside(ref, () => { setOpen(false); setQ(""); }, open); const filtered = options.filter(o => getLabel(o).toLowerCase().includes(q.trim().toLowerCase())); return (
{open && (
setQ(e.target.value)} />
{filtered.length === 0 &&
{emptyText}
} {filtered.map(o => ( ))}
)}
); } // Compact date field — shows the selected date as a field and opens the // calendar in a popover on click (instead of an always-open month grid). function DateField({ value, onChange, min, placeholder = "Select a date" }) { const { fmtDateLong } = window.TQ; const [open, setOpen] = useState(false); const ref = useRef(null); useClickOutside(ref, () => setOpen(false), open); return (
{open && (
{ onChange(v); setOpen(false); }} min={min} />
)}
); } // Toasts function ToastHost({ toasts }) { return (
{toasts.map(t => (
{t.icon ? React.createElement(t.icon, { size: 14 }) : }
{t.text}
))}
); } window.Avatar = Avatar; window.useClickOutside = useClickOutside; window.Calendar = Calendar; window.DateField = DateField; window.SearchableSelect = SearchableSelect; window.ToastHost = ToastHost;