284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
||
import { Check, X } from "lucide-react";
|
||
import type { Event } from "../../../services/api/types";
|
||
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
||
import { tagChipStyle } from "./participantTagUtils";
|
||
|
||
export type BulkDialogKind = null | "addTags" | "removeTags" | "assignEvents" | "newList" | "addToList";
|
||
|
||
export function ParticipantBulkDialogs({
|
||
bulkDialog,
|
||
bulkBusy,
|
||
selectedCount,
|
||
selectedTagValues,
|
||
onToggleTag,
|
||
availableTags,
|
||
removeTagOptions,
|
||
tagScope = "global",
|
||
newListName,
|
||
onNewListNameChange,
|
||
newListDesc,
|
||
onNewListDescChange,
|
||
eventsPick,
|
||
eventPickIds,
|
||
onToggleEventPick,
|
||
fixedLists,
|
||
addToListId,
|
||
onAddToListIdChange,
|
||
onClose,
|
||
onApplyTags,
|
||
onAssignEvents,
|
||
onCreateList,
|
||
onAddToList,
|
||
}: {
|
||
bulkDialog: BulkDialogKind;
|
||
bulkBusy: boolean;
|
||
selectedCount: number;
|
||
selectedTagValues: string[];
|
||
onToggleTag: (tag: string) => void;
|
||
availableTags: string[];
|
||
removeTagOptions: string[];
|
||
/** Event page = tags stored on this event membership; global = org-wide root tags. */
|
||
tagScope?: "event" | "global";
|
||
newListName: string;
|
||
onNewListNameChange: (v: string) => void;
|
||
newListDesc: string;
|
||
onNewListDescChange: (v: string) => void;
|
||
eventsPick: Event[];
|
||
eventPickIds: Set<string>;
|
||
onToggleEventPick: (id: string) => void;
|
||
fixedLists: ParticipantListRecord[];
|
||
addToListId: string;
|
||
onAddToListIdChange: (id: string) => void;
|
||
onClose: () => void;
|
||
onApplyTags: (action: "add_tags" | "remove_tags", overrideTags?: string[]) => void;
|
||
onAssignEvents: () => void;
|
||
onCreateList: () => void;
|
||
onAddToList: () => void;
|
||
}) {
|
||
const [manualTagInput, setManualTagInput] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (bulkDialog !== "addTags" && bulkDialog !== "removeTags") setManualTagInput("");
|
||
}, [bulkDialog]);
|
||
|
||
const addManualTags = () => {
|
||
const tokens = manualTagInput
|
||
.split(/[\n,;]+/)
|
||
.map((token) => token.trim())
|
||
.filter(Boolean);
|
||
|
||
if (!tokens.length) return [];
|
||
|
||
tokens.forEach((token) => {
|
||
if (!selectedTagValues.includes(token)) onToggleTag(token);
|
||
});
|
||
setManualTagInput("");
|
||
return Array.from(new Set([...selectedTagValues, ...tokens]));
|
||
};
|
||
|
||
const overlay = (children: React.ReactNode) => (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 205, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||
onClick={() => !bulkBusy && onClose()}
|
||
>
|
||
<div
|
||
style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px", width: "min(440px, 92vw)", maxHeight: "80vh", overflow: "auto", boxShadow: "var(--shadow-xl)" }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const footer = (primary: React.ReactNode) => (
|
||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "14px" }}>
|
||
<button type="button" disabled={bulkBusy} onClick={onClose} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Cancel</button>
|
||
{primary}
|
||
</div>
|
||
);
|
||
|
||
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
|
||
const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags;
|
||
const emptyMessage =
|
||
bulkDialog === "removeTags"
|
||
? "No tags are currently assigned to the selected participants."
|
||
: tagScope === "event"
|
||
? "No tags are available yet for this event."
|
||
: "No tags are available yet for this organisation.";
|
||
const scopeHint =
|
||
tagScope === "event"
|
||
? bulkDialog === "addTags"
|
||
? "Tags apply to this event only (also shown on the person’s global record, not on other events)."
|
||
: "Removes tags from this event only. Organisation-wide tags of the same name stay visible here."
|
||
: bulkDialog === "addTags"
|
||
? "Tags apply organisation-wide and appear on every event for these people."
|
||
: "Removes the tag from the person entirely (organisation-wide and any event-specific copies).";
|
||
|
||
return overlay(
|
||
<>
|
||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{scopeHint}</p>
|
||
<input
|
||
value={manualTagInput}
|
||
onChange={(e) => setManualTagInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
||
e.preventDefault();
|
||
addManualTags();
|
||
}
|
||
}}
|
||
placeholder={bulkDialog === "addTags" ? "Type a tag and press Enter" : "Type a tag and press Enter"}
|
||
style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", boxSizing: "border-box", marginBottom: "10px" }}
|
||
/>
|
||
{selectedTagValues.length > 0 && (
|
||
<div style={{ marginBottom: "10px" }}>
|
||
<div style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", fontWeight: 600, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: "6px" }}>Selected</div>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
||
{selectedTagValues.map((tag) => {
|
||
const tc = tagChipStyle(tag);
|
||
return (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => onToggleTag(tag)}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
padding: "6px 10px",
|
||
borderRadius: "999px",
|
||
border: "1px solid var(--border)",
|
||
background: tc.bg,
|
||
color: tc.color,
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
}}
|
||
>
|
||
<X size={12} />
|
||
{tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{tagOptions.length === 0 ? (
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 8px" }}>{emptyMessage}</p>
|
||
) : (
|
||
<div>
|
||
<div style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", fontWeight: 600, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: "6px" }}></div>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginBottom: "10px" }}>
|
||
{tagOptions
|
||
.filter(tag => !selectedTagValues.includes(tag))
|
||
.map((tag) => {
|
||
const active = selectedTagValues.includes(tag);
|
||
const tc = tagChipStyle(tag);
|
||
return (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => onToggleTag(tag)}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
padding: "6px 10px",
|
||
borderRadius: "999px",
|
||
border: `1px solid ${active ? "var(--primary)" : "var(--border)"}`,
|
||
background: active ? tc.bg : "var(--card)",
|
||
color: active ? tc.color : "var(--card-foreground)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
boxShadow: active ? `inset 0 0 0 1px ${tc.color}25` : "none",
|
||
}}
|
||
>
|
||
{active ? <Check size={12} /> : null}
|
||
{tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{footer(
|
||
<button
|
||
type="button"
|
||
disabled={bulkBusy || (selectedTagValues.length === 0 && !manualTagInput.trim())}
|
||
onClick={() => {
|
||
const manualTokens = addManualTags();
|
||
const effectiveTags = manualTokens.length ? manualTokens : selectedTagValues;
|
||
onApplyTags(bulkDialog === "addTags" ? "add_tags" : "remove_tags", effectiveTags);
|
||
}}
|
||
style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}
|
||
>
|
||
Apply
|
||
</button>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (bulkDialog === "assignEvents") {
|
||
return overlay(
|
||
<>
|
||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>Assign to event(s)</h3>
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{selectedCount} people · adds pending membership where missing.</p>
|
||
<div style={{ maxHeight: "240px", overflowY: "auto", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", padding: "8px" }}>
|
||
{eventsPick.map((ev) => (
|
||
<label key={ev._id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 4px", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
||
<input type="checkbox" checked={eventPickIds.has(ev._id)} onChange={() => onToggleEventPick(ev._id)} />
|
||
{ev.name}
|
||
</label>
|
||
))}
|
||
</div>
|
||
{footer(
|
||
<button type="button" disabled={bulkBusy || eventPickIds.size === 0} onClick={onAssignEvents} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Assign</button>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (bulkDialog === "newList") {
|
||
return overlay(
|
||
<>
|
||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>New custom list</h3>
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Fixed list with {selectedCount} selected people.</p>
|
||
<input value={newListName} onChange={(e) => onNewListNameChange(e.target.value)} placeholder="List name" style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", marginBottom: "8px", boxSizing: "border-box", fontSize: "var(--text-sm)" }} />
|
||
<textarea value={newListDesc} onChange={(e) => onNewListDescChange(e.target.value)} placeholder="Description (optional)" rows={3} style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", boxSizing: "border-box", fontSize: "var(--text-sm)", resize: "vertical" }} />
|
||
{footer(
|
||
<button type="button" disabled={bulkBusy || !newListName.trim()} onClick={onCreateList} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Create</button>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (bulkDialog === "addToList") {
|
||
return overlay(
|
||
<>
|
||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>Add to existing list</h3>
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Append {selectedCount} people to a fixed list.</p>
|
||
{fixedLists.length === 0 ? (
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>No fixed lists yet. Create one first.</p>
|
||
) : (
|
||
<select value={addToListId} onChange={(e) => onAddToListIdChange(e.target.value)} style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", fontSize: "var(--text-sm)" }}>
|
||
{fixedLists.map((L) => (
|
||
<option key={L._id} value={L._id}>{L.name}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{footer(
|
||
<button type="button" disabled={bulkBusy || fixedLists.length === 0 || !addToListId} onClick={onAddToList} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Add</button>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|