523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
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<Form>(() => (isEdit && editList ? formFromListRecord(editList) : emptyForm()));
|
|
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((p) => ({ ...p, [k]: v }));
|
|
|
|
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
|
const [events, setEvents] = useState<Event[]>([]);
|
|
const [optionsLoading, setOptionsLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveErr, setSaveErr] = useState<string | null>(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 (
|
|
<select
|
|
style={SS}
|
|
value={rule.value || "pending"}
|
|
onChange={(e) => updateRule(rule.id, "value", e.target.value)}
|
|
onFocus={focusIn}
|
|
onBlur={focusOut}
|
|
>
|
|
{STATUS_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
if (rule.field === "Tag") {
|
|
if (optionsLoading) {
|
|
return <span style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Loading tags…</span>;
|
|
}
|
|
if (tagOptions.length === 0) {
|
|
return <span style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>No tags yet. Add tags to participants first.</span>;
|
|
}
|
|
const v = rule.value && tagOptions.includes(rule.value) ? rule.value : tagOptions[0];
|
|
return (
|
|
<select style={SS} value={v} onChange={(e) => updateRule(rule.id, "value", e.target.value)} onFocus={focusIn} onBlur={focusOut}>
|
|
{tagOptions.map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
if (rule.field === "Event") {
|
|
if (optionsLoading) {
|
|
return <span style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Loading events…</span>;
|
|
}
|
|
if (events.length === 0) {
|
|
return <span style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>No events available.</span>;
|
|
}
|
|
const v = rule.value && events.some((e) => e._id === rule.value) ? rule.value : events[0]._id;
|
|
return (
|
|
<select style={SS} value={v} onChange={(e) => updateRule(rule.id, "value", e.target.value)} onFocus={focusIn} onBlur={focusOut}>
|
|
{events.map((ev) => (
|
|
<option key={ev._id} value={ev._id}>{ev.name}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
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 (
|
|
<PanelShell
|
|
onClose={onClose}
|
|
title={isEdit ? "Edit Custom List" : "Create Custom List"}
|
|
breadcrumb="Participants · Custom Lists"
|
|
description={
|
|
isEdit
|
|
? "Update name, rules, and settings for this list. Fields marked * are required."
|
|
: "Build a smart filtered list from your participant database. Fields marked * are required."
|
|
}
|
|
completedCount={acc.done.size}
|
|
totalSections={TOTAL}
|
|
ctaLabel={saving ? "Saving…" : saveLabel}
|
|
ctaDisabled={saving}
|
|
onCtaClick={submit}
|
|
>
|
|
{saveErr && (
|
|
<p style={{ margin: "0 20px 12px", fontSize: "var(--text-sm)", color: "var(--destructive)", fontFamily: "var(--font-noto-sans)" }}>{saveErr}</p>
|
|
)}
|
|
|
|
<AccordionSection
|
|
n={1}
|
|
title="List Details"
|
|
subtitle="Name and describe your custom list"
|
|
open={acc.open === 1}
|
|
completed={acc.done.has(1)}
|
|
onToggle={() => acc.toggle(1)}
|
|
onSave={() => acc.advance(1)}
|
|
nextLabel="Filter Rules"
|
|
>
|
|
<Field>
|
|
<Label text="List Name" required />
|
|
<input
|
|
style={IS}
|
|
placeholder="e.g. VIP Delegates — NAPCON 2026"
|
|
value={form.listName}
|
|
onChange={(e) => set("listName", e.target.value)}
|
|
onFocus={focusIn}
|
|
onBlur={focusOut}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<Label text="Description" />
|
|
<textarea
|
|
style={{ ...IS, resize: "vertical", minHeight: "72px" }}
|
|
placeholder="Briefly describe who this list is for…"
|
|
value={form.description}
|
|
onChange={(e) => set("description", e.target.value)}
|
|
onFocus={focusIn}
|
|
onBlur={focusOut}
|
|
/>
|
|
</Field>
|
|
</AccordionSection>
|
|
|
|
<AccordionSection
|
|
n={2}
|
|
title="Filter Rules"
|
|
subtitle="Define conditions to automatically populate this list"
|
|
open={acc.open === 2}
|
|
completed={acc.done.has(2)}
|
|
onToggle={() => acc.toggle(2)}
|
|
onSave={() => acc.advance(2)}
|
|
nextLabel="Settings"
|
|
>
|
|
{form.rules.length > 1 && (
|
|
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginBottom: "4px" }}>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Match</span>
|
|
{(["AND", "OR"] as const).map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => set("conjunction", c)}
|
|
style={{
|
|
padding: "4px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: `1px solid ${form.conjunction === c ? "var(--primary)" : "var(--border)"}`,
|
|
background: form.conjunction === c ? "var(--accent)" : "transparent",
|
|
color: form.conjunction === c ? "var(--primary)" : "var(--muted)",
|
|
cursor: "pointer",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
}}
|
|
>
|
|
{c}
|
|
</button>
|
|
))}
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>of the following rules</span>
|
|
</div>
|
|
)}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
{form.rules.map((rule, i) => (
|
|
<div
|
|
key={rule.id}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 1fr 1fr auto",
|
|
gap: "8px",
|
|
alignItems: "center",
|
|
padding: "10px 12px",
|
|
background: "var(--secondary)",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
{i > 0 && (
|
|
<span
|
|
style={{
|
|
gridColumn: "1/-1",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
color: "var(--muted)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
}}
|
|
>
|
|
{form.conjunction}
|
|
</span>
|
|
)}
|
|
<div style={{ position: "relative" }}>
|
|
<select
|
|
style={SS}
|
|
value={rule.field}
|
|
onChange={(e) => updateRuleField(rule.id, e.target.value)}
|
|
onFocus={focusIn}
|
|
onBlur={focusOut}
|
|
>
|
|
{FIELD_OPTIONS.map((o) => (
|
|
<option key={o} value={o}>{o}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ position: "relative" }}>
|
|
<select
|
|
style={SS}
|
|
value={rule.operator}
|
|
onChange={(e) => updateRule(rule.id, "operator", e.target.value)}
|
|
onFocus={focusIn}
|
|
onBlur={focusOut}
|
|
>
|
|
{OP_OPTIONS_IS.map((o) => (
|
|
<option key={o} value={o}>{o}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ position: "relative", minWidth: 0 }}>{renderValueControl(rule)}</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeRule(rule.id)}
|
|
disabled={form.rules.length === 1}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: "28px",
|
|
height: "28px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
color: form.rules.length === 1 ? "var(--muted-foreground)" : "var(--destructive)",
|
|
cursor: form.rules.length === 1 ? "not-allowed" : "pointer",
|
|
}}
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={addRule}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "7px 14px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px dashed var(--primary)",
|
|
background: "transparent",
|
|
color: "var(--primary)",
|
|
cursor: "pointer",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
}}
|
|
>
|
|
<Plus size={13} /> Add Rule
|
|
</button>
|
|
<HelperText text="Status, Tag, and Event use lists from your organization. Tag options come from participant tags; Event from your events." />
|
|
</AccordionSection>
|
|
|
|
<AccordionSection
|
|
n={3}
|
|
title="Settings"
|
|
subtitle="Configure visibility and update behaviour"
|
|
open={acc.open === 3}
|
|
completed={acc.done.has(3)}
|
|
onToggle={() => acc.toggle(3)}
|
|
onSave={() => void submit()}
|
|
nextLabel={saveLabel}
|
|
nextDisabled={saving}
|
|
>
|
|
<Grid cols={2}>
|
|
<Field>
|
|
<Label text="Visibility" required />
|
|
<RadioGroup
|
|
name="clVisibility"
|
|
value={form.visibility}
|
|
onChange={(v) => set("visibility", v as "Private" | "Shared")}
|
|
options={[{ label: "Private", value: "Private" }, { label: "Shared", value: "Shared" }]}
|
|
/>
|
|
<HelperText text="Shared lists are visible to all admins." />
|
|
</Field>
|
|
<Field>
|
|
<Label text="Auto Update" required />
|
|
<RadioGroup
|
|
name="clAutoUpdate"
|
|
value={form.autoUpdate}
|
|
onChange={(v) => set("autoUpdate", v as "Yes" | "No")}
|
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
|
/>
|
|
<HelperText text="Recompute rule-based members when you open the list." />
|
|
</Field>
|
|
</Grid>
|
|
</AccordionSection>
|
|
</PanelShell>
|
|
);
|
|
}
|