import React, { useState, useEffect } from "react"; import { Plus, Trash2 } from "lucide-react"; import { useAuth } from "../../contexts/AuthContext"; import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell"; import { participantListsApi, participantsApi, eventsApi, portfolioListOrganizationId } from "../../../services/api"; import type { Event, ParticipantListRecord } from "../../../services/api"; interface FilterRule { id: string; field: string; operator: string; value: string; } interface Form { listName: string; description: string; rules: FilterRule[]; conjunction: "AND" | "OR"; visibility: "Private" | "Shared"; autoUpdate: "Yes" | "No"; } const FIELD_OPTIONS = ["Status", "Tag", "Event"] as const; const OP_OPTIONS_IS = ["is", "is not"] as const; const STATUS_OPTIONS: { label: string; value: string }[] = [ { label: "Pending", value: "pending" }, { label: "Confirmed", value: "confirmed" }, { label: "Checked In", value: "checked_in" }, { label: "Cancelled", value: "cancelled" }, { label: "Rejected", value: "rejected" }, ]; const emptyForm = (): Form => ({ listName: "", description: "", rules: [{ id: `r-${Math.random().toString(36).slice(2)}`, field: "Status", operator: "is", value: "pending" }], conjunction: "AND", visibility: "Private", autoUpdate: "Yes", }); function newRuleId(i: number) { return `rule-${i}-${Math.random().toString(36).slice(2, 10)}`; } function normalizeApiRule(x: { field?: string; operator?: string; value?: string }, i: number): FilterRule { const fieldRaw = String(x.field ?? "Status"); const field = (FIELD_OPTIONS as readonly string[]).includes(fieldRaw) ? fieldRaw : "Status"; const opRaw = String(x.operator ?? "is").trim().toLowerCase(); const operator: FilterRule["operator"] = opRaw === "is not" ? "is not" : "is"; let value = String(x.value ?? ""); if (field === "Status") { const ok = STATUS_OPTIONS.some((s) => s.value === value); if (!ok) value = "pending"; } return { id: newRuleId(i), field, operator, value }; } function formFromListRecord(doc: ParticipantListRecord): Form { const r = doc.rules; const raw = Array.isArray(r?.rules) ? r.rules : []; const rules: FilterRule[] = raw.length ? raw.map((x, i) => normalizeApiRule(x, i)) : [{ id: newRuleId(0), field: "Status", operator: "is", value: "pending" }]; return { listName: doc.name, description: doc.description ?? "", rules, conjunction: r?.conjunction === "OR" ? "OR" : "AND", visibility: doc.visibility === "shared" ? "Shared" : "Private", autoUpdate: doc.autoUpdate !== false ? "Yes" : "No", }; } export function CreateCustomListPanel({ onClose, onCreated, onSaved, editList = null, }: { onClose: () => void; onCreated?: () => void; /** Called after a rule-based list is updated (edit mode). */ onSaved?: (list: ParticipantListRecord) => void; /** When set to a rule-based list, the panel opens in edit mode (same form, saves via PUT). */ editList?: ParticipantListRecord | null; }) { const { user } = useAuth(); const listOrgId = portfolioListOrganizationId(user); const TOTAL = 3; const acc = useAccordion(TOTAL); const isEdit = Boolean(editList?.type === "rule_based"); const [form, setForm] = useState
(() => (isEdit && editList ? formFromListRecord(editList) : emptyForm())); const set = (k: K, v: Form[K]) => setForm((p) => ({ ...p, [k]: v })); const [tagOptions, setTagOptions] = useState([]); const [events, setEvents] = useState([]); const [optionsLoading, setOptionsLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveErr, setSaveErr] = useState(null); useEffect(() => { let cancelled = false; if (!listOrgId) { setOptionsLoading(false); return; } setOptionsLoading(true); Promise.all([ participantsApi.listDistinctTags({ organization: listOrgId }), eventsApi.list({ limit: 400, organization: listOrgId }), ]).then(([tr, er]) => { if (cancelled) return; if (tr.data?.tags) setTagOptions(tr.data.tags); if (er.data?.events) setEvents(er.data.events); setOptionsLoading(false); }).catch(() => { if (!cancelled) setOptionsLoading(false); }); return () => { cancelled = true; }; }, [listOrgId]); useEffect(() => { if (!editList || editList.type !== "rule_based") { setForm(emptyForm()); return; } setForm(formFromListRecord(editList)); let cancelled = false; participantListsApi.get(editList._id).then((res) => { if (cancelled || res.error || !res.data) return; const doc = res.data as ParticipantListRecord; if (doc.type === "rule_based") setForm(formFromListRecord(doc)); }); return () => { cancelled = true; }; }, [editList?._id]); useEffect(() => { if (!tagOptions.length) return; setForm((p) => ({ ...p, rules: p.rules.map((r) => { if (r.field !== "Tag") return r; if (r.value && tagOptions.includes(r.value)) return r; return { ...r, value: tagOptions[0] }; }), })); }, [tagOptions]); useEffect(() => { if (!events.length) return; setForm((p) => ({ ...p, rules: p.rules.map((r) => { if (r.field !== "Event") return r; if (r.value && events.some((e) => e._id === r.value)) return r; return { ...r, value: events[0]._id }; }), })); }, [events]); const addRule = () => setForm((p) => ({ ...p, rules: [...p.rules, { id: Math.random().toString(36).slice(2), field: "Status", operator: "is", value: "pending" }], })); const removeRule = (id: string) => setForm((p) => ({ ...p, rules: p.rules.filter((r) => r.id !== id) })); const updateRule = (id: string, k: keyof FilterRule, v: string) => setForm((p) => ({ ...p, rules: p.rules.map((r) => (r.id === id ? { ...r, [k]: v } : r)) })); const updateRuleField = (id: string, field: string) => { let defaultValue = ""; if (field === "Status") defaultValue = "pending"; else if (field === "Tag" && tagOptions[0]) defaultValue = tagOptions[0]; else if (field === "Event" && events[0]) defaultValue = events[0]._id; setForm((p) => ({ ...p, rules: p.rules.map((r) => r.id === id ? { ...r, field, operator: "is", value: defaultValue } : r ), })); }; const renderValueControl = (rule: FilterRule) => { if (rule.field === "Status") { return ( ); } if (rule.field === "Tag") { if (optionsLoading) { return Loading tags…; } if (tagOptions.length === 0) { return No tags yet. Add tags to participants first.; } const v = rule.value && tagOptions.includes(rule.value) ? rule.value : tagOptions[0]; return ( ); } if (rule.field === "Event") { if (optionsLoading) { return Loading events…; } if (events.length === 0) { return No events available.; } const v = rule.value && events.some((e) => e._id === rule.value) ? rule.value : events[0]._id; return ( ); } return null; }; const submit = async () => { setSaveErr(null); if (!form.listName.trim()) { setSaveErr("List name is required."); return; } const invalid = form.rules.some((r) => { if (r.field === "Tag" && !tagOptions.length) return true; if (r.field === "Event" && !events.length) return true; return false; }); if (invalid) { setSaveErr("Each rule needs a valid value. Add tags or events, or adjust rules."); return; } setSaving(true); try { const rulesPayload = { rules: form.rules.map(({ field, operator, value }) => ({ field, operator, value })), conjunction: form.conjunction, }; if (isEdit && editList) { const res = await participantListsApi.update(editList._id, { name: form.listName.trim(), description: form.description.trim(), visibility: form.visibility === "Shared" ? "shared" : "private", type: "rule_based", autoUpdate: form.autoUpdate === "Yes", rules: rulesPayload, }); if (res.error) { setSaveErr(res.error || "Could not update list."); setSaving(false); return; } if (res.data) onSaved?.(res.data as ParticipantListRecord); onClose(); } else { const res = await participantListsApi.create({ name: form.listName.trim(), description: form.description.trim(), visibility: form.visibility === "Shared" ? "shared" : "private", type: "rule_based", autoUpdate: form.autoUpdate === "Yes", rules: rulesPayload, }); if (res.error) { setSaveErr(res.error || "Could not create list."); setSaving(false); return; } onCreated?.(); onClose(); } } catch { setSaveErr(isEdit ? "Could not update list." : "Could not create list."); } setSaving(false); }; const saveLabel = isEdit ? "Save changes" : "Create List"; return ( {saveErr && (

{saveErr}

)} acc.toggle(1)} onSave={() => acc.advance(1)} nextLabel="Filter Rules" >