Update participant management and form submission modules
This commit is contained in:
parent
6e7665a545
commit
67d318cc9c
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { BarChart2, CalendarDays, FileText, Plus, ShieldCheck, Tag, Ticket, Trophy } from "lucide-react";
|
import { BarChart2, CalendarDays, FileText, Plus, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { eventsApi } from "../../../services/api/events";
|
import { eventsApi } from "../../../services/api/events";
|
||||||
|
|
@ -12,6 +12,7 @@ import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||||
|
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||||
import type { Question } from "../quiz/AddQuestionModal";
|
import type { Question } from "../quiz/AddQuestionModal";
|
||||||
|
|
@ -186,6 +187,7 @@ export function EventHomeModules() {
|
||||||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||||
|
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
|
|
@ -750,32 +752,39 @@ export function EventHomeModules() {
|
||||||
meta={webCheckInMeta}
|
meta={webCheckInMeta}
|
||||||
description="Show or hide accommodation choice for participants"
|
description="Show or hide accommodation choice for participants"
|
||||||
actions={
|
actions={
|
||||||
<>
|
webCheckInEnabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled || webCheckInLoading}
|
disabled={actionsDisabled || webCheckInLoading}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(),
|
...moduleButtonStyle(),
|
||||||
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
||||||
cursor: actionsDisabled || webCheckInLoading ? "not-allowed" : "pointer",
|
cursor:
|
||||||
|
actionsDisabled || webCheckInLoading
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => void handleWebCheckInToggle(true)}
|
onClick={() => void handleWebCheckInToggle(false)}
|
||||||
>
|
>
|
||||||
Enable
|
Disable
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled || webCheckInLoading}
|
disabled={actionsDisabled || webCheckInLoading}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(true),
|
...moduleButtonStyle(true),
|
||||||
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
||||||
cursor: actionsDisabled || webCheckInLoading ? "not-allowed" : "pointer",
|
cursor:
|
||||||
|
actionsDisabled || webCheckInLoading
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => void handleWebCheckInToggle(false)}
|
onClick={() => void handleWebCheckInToggle(true)}
|
||||||
>
|
>
|
||||||
Disable
|
Enable
|
||||||
</button>
|
</button>
|
||||||
</>
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
|
|
@ -855,6 +864,25 @@ export function EventHomeModules() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
icon={<Upload size={18} />}
|
||||||
|
title="Upload Document"
|
||||||
|
description="Manage documents uploaded for this event"
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowDocumentsPanel(true)}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{formEditTarget && selectedEventId ? (
|
{formEditTarget && selectedEventId ? (
|
||||||
|
|
@ -935,6 +963,14 @@ export function EventHomeModules() {
|
||||||
onSaved={() => void loadRewardRulesMeta()}
|
onSaved={() => void loadRewardRulesMeta()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showDocumentsPanel && selectedEventId ? (
|
||||||
|
<EventDocumentsPanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
eventName={eventName ?? undefined}
|
||||||
|
onClose={() => setShowDocumentsPanel(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1429,8 +1429,10 @@ export function AllParticipantsPage() {
|
||||||
bulkDialog={bulk.bulkDialog}
|
bulkDialog={bulk.bulkDialog}
|
||||||
bulkBusy={bulk.bulkBusy}
|
bulkBusy={bulk.bulkBusy}
|
||||||
selectedCount={selectedIds.length}
|
selectedCount={selectedIds.length}
|
||||||
tagBulkInput={bulk.tagBulkInput}
|
selectedTagValues={bulk.selectedTagValues}
|
||||||
onTagBulkInputChange={bulk.setTagBulkInput}
|
onToggleTag={bulk.toggleSelectedTag}
|
||||||
|
availableTags={tagOptions}
|
||||||
|
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||||
newListName={bulk.newListName}
|
newListName={bulk.newListName}
|
||||||
onNewListNameChange={bulk.setNewListName}
|
onNewListNameChange={bulk.setNewListName}
|
||||||
newListDesc={bulk.newListDesc}
|
newListDesc={bulk.newListDesc}
|
||||||
|
|
@ -1442,7 +1444,7 @@ export function AllParticipantsPage() {
|
||||||
addToListId={bulk.addToListId}
|
addToListId={bulk.addToListId}
|
||||||
onAddToListIdChange={bulk.setAddToListId}
|
onAddToListIdChange={bulk.setAddToListId}
|
||||||
onClose={() => bulk.setBulkDialog(null)}
|
onClose={() => bulk.setBulkDialog(null)}
|
||||||
onApplyTags={(action) => void bulk.runBulkTags(action)}
|
onApplyTags={(action, overrideTags) => void bulk.runBulkTags(action, overrideTags)}
|
||||||
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
||||||
onCreateList={() => void bulk.runBulkNewList()}
|
onCreateList={() => void bulk.runBulkNewList()}
|
||||||
onAddToList={() => void bulk.runBulkAddToList()}
|
onAddToList={() => void bulk.runBulkAddToList()}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { X, Download, ChevronLeft, ChevronRight } from "lucide-react";
|
import { X, Download, ChevronLeft, ChevronRight, MailPlus } from "lucide-react";
|
||||||
import { formsApi } from "../../../services/api";
|
import { formsApi } from "../../../services/api";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import type { Form, FormField, FormSubmissionRecord } from "../../../services/api";
|
import type { Form, FormField, FormSubmissionRecord } from "../../../services/api";
|
||||||
|
|
||||||
function dataToObj(data: unknown): Record<string, unknown> {
|
function dataToObj(data: unknown): Record<string, unknown> {
|
||||||
|
|
@ -27,6 +28,11 @@ function participantEmail(
|
||||||
return p.email?.trim() || "—";
|
return p.email?.trim() || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function participantName(p: FormSubmissionRecord["participant"]): string {
|
||||||
|
if (!p || typeof p === "string") return "there";
|
||||||
|
return [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "there";
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
formId: string;
|
formId: string;
|
||||||
formName: string;
|
formName: string;
|
||||||
|
|
@ -51,6 +57,12 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [replyTarget, setReplyTarget] = useState<FormSubmissionRecord | null>(null);
|
||||||
|
const [replySubject, setReplySubject] = useState("");
|
||||||
|
const [replyBody, setReplyBody] = useState("");
|
||||||
|
const [replying, setReplying] = useState(false);
|
||||||
|
const [replyMessage, setReplyMessage] = useState<string | null>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const fieldLabels = (formDef?.fields || [])
|
const fieldLabels = (formDef?.fields || [])
|
||||||
.map((f: FormField) => f.label?.trim())
|
.map((f: FormField) => f.label?.trim())
|
||||||
|
|
@ -127,6 +139,41 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
const openReply = (submission: FormSubmissionRecord) => {
|
||||||
|
const email = participantEmail(submission.participant);
|
||||||
|
if (email === "—") {
|
||||||
|
setReplyMessage("This submission does not include an email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReplyTarget(submission);
|
||||||
|
setReplySubject(`Re: Your response to ${formName}`);
|
||||||
|
setReplyBody(`Hi ${participantName(submission.participant)},\n\nThank you for your response.\n\nBest regards,\n${user?.firstName || user?.email || "EventBuzz"}`);
|
||||||
|
setReplyMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendReply = async () => {
|
||||||
|
if (!replyTarget) return;
|
||||||
|
const to = participantEmail(replyTarget.participant);
|
||||||
|
if (!to || to === "—") {
|
||||||
|
setReplyMessage("Please choose a submission with a valid email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReplying(true);
|
||||||
|
setReplyMessage(null);
|
||||||
|
try {
|
||||||
|
await formsApi.replyToSubmission(formId, { to, subject: replySubject, body: replyBody });
|
||||||
|
setReplyMessage("Email sent successfully.");
|
||||||
|
setReplyTarget(null);
|
||||||
|
setReplySubject("");
|
||||||
|
setReplyBody("");
|
||||||
|
} catch (e) {
|
||||||
|
setReplyMessage(e instanceof Error ? e.message : "Failed to send reply.");
|
||||||
|
} finally {
|
||||||
|
setReplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -380,6 +427,7 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
<tr style={{ background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Reply</th>
|
||||||
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Submitted</th>
|
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Submitted</th>
|
||||||
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Email</th>
|
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Email</th>
|
||||||
{tableFieldLabels.map((lab) => (
|
{tableFieldLabels.map((lab) => (
|
||||||
|
|
@ -402,6 +450,29 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
const o = dataToObj(s.data);
|
const o = dataToObj(s.data);
|
||||||
return (
|
return (
|
||||||
<tr key={s._id} style={{ borderBottom: "1px solid var(--border)", verticalAlign: "top" }}>
|
<tr key={s._id} style={{ borderBottom: "1px solid var(--border)", verticalAlign: "top" }}>
|
||||||
|
<td style={{ padding: "10px 12px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openReply(s)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--secondary)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MailPlus size={14} />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td style={{ padding: "10px 12px", whiteSpace: "nowrap", color: "var(--foreground)" }}>
|
<td style={{ padding: "10px 12px", whiteSpace: "nowrap", color: "var(--foreground)" }}>
|
||||||
{formatWhen(s.submittedAt)}
|
{formatWhen(s.submittedAt)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -428,6 +499,53 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{replyTarget ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 18px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Reply to {participantEmail(replyTarget.participant)}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
This sends a real email from the connected mailbox configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => { setReplyTarget(null); setReplySubject(""); setReplyBody(""); setReplyMessage(null); }} style={{ border: "none", background: "transparent", color: "var(--muted)", cursor: "pointer" }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={replySubject}
|
||||||
|
onChange={(e) => setReplySubject(e.target.value)}
|
||||||
|
placeholder="Subject"
|
||||||
|
style={{ padding: "8px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={replyBody}
|
||||||
|
onChange={(e) => setReplyBody(e.target.value)}
|
||||||
|
placeholder="Write your message here"
|
||||||
|
rows={8}
|
||||||
|
style={{ padding: "8px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--foreground)", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
{replyMessage ? <p style={{ margin: 0, fontSize: "var(--text-xs)", color: replyMessage.includes("success") ? "var(--success)" : "var(--destructive)" }}>{replyMessage}</p> : null}
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<button type="button" onClick={() => void sendReply()} disabled={replying} style={{ padding: "8px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: replying ? "not-allowed" : "pointer", opacity: replying ? 0.7 : 1 }}>
|
||||||
|
{replying ? "Sending…" : "Send reply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 18px",
|
padding: "12px 18px",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
import type { Event } from "../../../services/api/types";
|
import type { Event } from "../../../services/api/types";
|
||||||
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
||||||
|
import { tagChipStyle } from "./participantTagUtils";
|
||||||
|
|
||||||
export type BulkDialogKind = null | "addTags" | "removeTags" | "assignEvents" | "newList" | "addToList";
|
export type BulkDialogKind = null | "addTags" | "removeTags" | "assignEvents" | "newList" | "addToList";
|
||||||
|
|
||||||
|
|
@ -8,8 +10,10 @@ export function ParticipantBulkDialogs({
|
||||||
bulkDialog,
|
bulkDialog,
|
||||||
bulkBusy,
|
bulkBusy,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
tagBulkInput,
|
selectedTagValues,
|
||||||
onTagBulkInputChange,
|
onToggleTag,
|
||||||
|
availableTags,
|
||||||
|
removeTagOptions,
|
||||||
newListName,
|
newListName,
|
||||||
onNewListNameChange,
|
onNewListNameChange,
|
||||||
newListDesc,
|
newListDesc,
|
||||||
|
|
@ -29,8 +33,10 @@ export function ParticipantBulkDialogs({
|
||||||
bulkDialog: BulkDialogKind;
|
bulkDialog: BulkDialogKind;
|
||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
tagBulkInput: string;
|
selectedTagValues: string[];
|
||||||
onTagBulkInputChange: (v: string) => void;
|
onToggleTag: (tag: string) => void;
|
||||||
|
availableTags: string[];
|
||||||
|
removeTagOptions: string[];
|
||||||
newListName: string;
|
newListName: string;
|
||||||
onNewListNameChange: (v: string) => void;
|
onNewListNameChange: (v: string) => void;
|
||||||
newListDesc: string;
|
newListDesc: string;
|
||||||
|
|
@ -42,11 +48,32 @@ export function ParticipantBulkDialogs({
|
||||||
addToListId: string;
|
addToListId: string;
|
||||||
onAddToListIdChange: (id: string) => void;
|
onAddToListIdChange: (id: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onApplyTags: (action: "add_tags" | "remove_tags") => void;
|
onApplyTags: (action: "add_tags" | "remove_tags", overrideTags?: string[]) => void;
|
||||||
onAssignEvents: () => void;
|
onAssignEvents: () => void;
|
||||||
onCreateList: () => void;
|
onCreateList: () => void;
|
||||||
onAddToList: () => 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) => (
|
const overlay = (children: React.ReactNode) => (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
@ -71,13 +98,110 @@ export function ParticipantBulkDialogs({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
|
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
|
||||||
|
const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags;
|
||||||
|
const emptyMessage = bulkDialog === "removeTags" ? "No tags are currently assigned to the selected participants." : "No tags are available yet for this event.";
|
||||||
|
|
||||||
return overlay(
|
return overlay(
|
||||||
<>
|
<>
|
||||||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
|
<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 12px", fontFamily: "var(--font-noto-sans)" }}>Apply to {selectedCount} selected (comma-separated).</p>
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
|
||||||
<input value={tagBulkInput} onChange={(e) => onTagBulkInputChange(e.target.value)} placeholder="e.g. VIP, Sponsor" style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", boxSizing: "border-box" }} />
|
<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(
|
{footer(
|
||||||
<button type="button" disabled={bulkBusy} onClick={() => onApplyTags(bulkDialog === "addTags" ? "add_tags" : "remove_tags")} 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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1093,8 +1093,10 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
bulkDialog={bulk.bulkDialog}
|
bulkDialog={bulk.bulkDialog}
|
||||||
bulkBusy={bulk.bulkBusy}
|
bulkBusy={bulk.bulkBusy}
|
||||||
selectedCount={selectedIds.length}
|
selectedCount={selectedIds.length}
|
||||||
tagBulkInput={bulk.tagBulkInput}
|
selectedTagValues={bulk.selectedTagValues}
|
||||||
onTagBulkInputChange={bulk.setTagBulkInput}
|
onToggleTag={bulk.toggleSelectedTag}
|
||||||
|
availableTags={tagOptions}
|
||||||
|
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||||
newListName={bulk.newListName}
|
newListName={bulk.newListName}
|
||||||
onNewListNameChange={bulk.setNewListName}
|
onNewListNameChange={bulk.setNewListName}
|
||||||
newListDesc={bulk.newListDesc}
|
newListDesc={bulk.newListDesc}
|
||||||
|
|
@ -1106,7 +1108,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
addToListId={bulk.addToListId}
|
addToListId={bulk.addToListId}
|
||||||
onAddToListIdChange={bulk.setAddToListId}
|
onAddToListIdChange={bulk.setAddToListId}
|
||||||
onClose={() => bulk.setBulkDialog(null)}
|
onClose={() => bulk.setBulkDialog(null)}
|
||||||
onApplyTags={(action) => void bulk.runBulkTags(action)}
|
onApplyTags={(action, overrideTags) => void bulk.runBulkTags(action, overrideTags)}
|
||||||
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
||||||
onCreateList={() => void bulk.runBulkNewList()}
|
onCreateList={() => void bulk.runBulkNewList()}
|
||||||
onAddToList={() => void bulk.runBulkAddToList()}
|
onAddToList={() => void bulk.runBulkAddToList()}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { participantListsApi } from "../../../services/api/participantLists";
|
||||||
import { eventsApi } from "../../../services/api/events";
|
import { eventsApi } from "../../../services/api/events";
|
||||||
import type { Event } from "../../../services/api/types";
|
import type { Event } from "../../../services/api/types";
|
||||||
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
||||||
import { parseTagsBulk } from "./participantTagUtils";
|
|
||||||
import type { BulkDialogKind } from "./ParticipantBulkDialogs";
|
import type { BulkDialogKind } from "./ParticipantBulkDialogs";
|
||||||
|
|
||||||
export function useParticipantBulkActions(options: {
|
export function useParticipantBulkActions(options: {
|
||||||
|
|
@ -19,7 +18,7 @@ export function useParticipantBulkActions(options: {
|
||||||
|
|
||||||
const [bulkDialog, setBulkDialog] = useState<BulkDialogKind>(null);
|
const [bulkDialog, setBulkDialog] = useState<BulkDialogKind>(null);
|
||||||
const [bulkBusy, setBulkBusy] = useState(false);
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
const [tagBulkInput, setTagBulkInput] = useState("");
|
const [selectedTagValues, setSelectedTagValues] = useState<string[]>([]);
|
||||||
const [newListName, setNewListName] = useState("");
|
const [newListName, setNewListName] = useState("");
|
||||||
const [newListDesc, setNewListDesc] = useState("");
|
const [newListDesc, setNewListDesc] = useState("");
|
||||||
const [eventsPick, setEventsPick] = useState<Event[]>([]);
|
const [eventsPick, setEventsPick] = useState<Event[]>([]);
|
||||||
|
|
@ -27,8 +26,12 @@ export function useParticipantBulkActions(options: {
|
||||||
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
||||||
const [addToListId, setAddToListId] = useState("");
|
const [addToListId, setAddToListId] = useState("");
|
||||||
|
|
||||||
|
const toggleSelectedTag = (tag: string) => {
|
||||||
|
setSelectedTagValues((prev) => prev.includes(tag) ? prev.filter((value) => value !== tag) : [...prev, tag]);
|
||||||
|
};
|
||||||
|
|
||||||
const openBulkDialog = async (kind: NonNullable<BulkDialogKind>) => {
|
const openBulkDialog = async (kind: NonNullable<BulkDialogKind>) => {
|
||||||
setTagBulkInput("");
|
setSelectedTagValues([]);
|
||||||
setNewListName("");
|
setNewListName("");
|
||||||
setNewListDesc("");
|
setNewListDesc("");
|
||||||
setAddToListId("");
|
setAddToListId("");
|
||||||
|
|
@ -63,8 +66,8 @@ export function useParticipantBulkActions(options: {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const runBulkTags = async (action: "add_tags" | "remove_tags") => {
|
const runBulkTags = async (action: "add_tags" | "remove_tags", overrideTags?: string[]) => {
|
||||||
const tags = parseTagsBulk(tagBulkInput);
|
const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean);
|
||||||
if (!tags.length) return;
|
if (!tags.length) return;
|
||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags });
|
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags });
|
||||||
|
|
@ -158,8 +161,8 @@ export function useParticipantBulkActions(options: {
|
||||||
bulkDialog,
|
bulkDialog,
|
||||||
setBulkDialog,
|
setBulkDialog,
|
||||||
bulkBusy,
|
bulkBusy,
|
||||||
tagBulkInput,
|
selectedTagValues,
|
||||||
setTagBulkInput,
|
toggleSelectedTag,
|
||||||
newListName,
|
newListName,
|
||||||
setNewListName,
|
setNewListName,
|
||||||
newListDesc,
|
newListDesc,
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,15 @@ export const formsApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** CSV of all rows matching the same filters as the list (not paginated). */
|
/** CSV of all rows matching the same filters as the list (not paginated). */
|
||||||
|
async replyToSubmission(
|
||||||
|
formId: string,
|
||||||
|
payload: { to: string; subject?: string; body?: string }
|
||||||
|
): Promise<{ sent: boolean }> {
|
||||||
|
const r = await api.post<{ sent: boolean }>(`/forms/${formId}/submissions/reply`, payload);
|
||||||
|
if (r.error) throw new Error(r.error);
|
||||||
|
return r.data ?? { sent: false };
|
||||||
|
},
|
||||||
|
|
||||||
async exportSubmissionsCsv(
|
async exportSubmissionsCsv(
|
||||||
formId: string,
|
formId: string,
|
||||||
params?: { q?: string; dateFrom?: string; dateTo?: string }
|
params?: { q?: string; dateFrom?: string; dateTo?: string }
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface ListMediaParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
eventId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMedia(params: ListMediaParams = {}): Promise<{ media: LibraryContent[]; total: number }> {
|
export async function listMedia(params: ListMediaParams = {}): Promise<{ media: LibraryContent[]; total: number }> {
|
||||||
|
|
@ -27,7 +28,9 @@ export async function listMedia(params: ListMediaParams = {}): Promise<{ media:
|
||||||
q.set('page', String(params.page ?? 1));
|
q.set('page', String(params.page ?? 1));
|
||||||
q.set('limit', String(params.limit ?? 200));
|
q.set('limit', String(params.limit ?? 200));
|
||||||
if (params.type) q.set('type', params.type);
|
if (params.type) q.set('type', params.type);
|
||||||
const r = await api.get<{ media: LibraryContent[]; total: number }>(`/library/media?${q.toString()}`);
|
if (params.eventId) q.set('eventId', params.eventId);
|
||||||
|
const endpoint = params.eventId ? '/media' : '/library/media';
|
||||||
|
const r = await api.get<{ media: LibraryContent[]; total: number }>(`${endpoint}?${q.toString()}`);
|
||||||
if (r.error) throw new Error(r.error);
|
if (r.error) throw new Error(r.error);
|
||||||
return { media: r.data?.media ?? [], total: r.data?.total ?? 0 };
|
return { media: r.data?.media ?? [], total: r.data?.total ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -615,6 +615,7 @@ export interface Speaker {
|
||||||
export interface LibraryContent {
|
export interface LibraryContent {
|
||||||
_id: string;
|
_id: string;
|
||||||
organization?: string;
|
organization?: string;
|
||||||
|
eventId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
visibility?: 'private' | 'public';
|
visibility?: 'private' | 'public';
|
||||||
|
|
@ -625,7 +626,7 @@ export interface LibraryContent {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
collectionName?: string;
|
collectionName?: string;
|
||||||
s3Key?: string;
|
s3Key?: string;
|
||||||
uploadedBy?: string;
|
uploadedBy?: string | { firstName?: string; lastName?: string; email?: string; phone?: string };
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue