EventBuzz_Console/src/app/components/panels/CreateCustomListPanel.tsx

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>
);
}