Compare commits
2 Commits
aed52029c6
...
cab7f25f91
| Author | SHA1 | Date |
|---|---|---|
|
|
cab7f25f91 | |
|
|
67d318cc9c |
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
<<<<<<< HEAD
|
||||
import { BarChart2, CalendarDays, FileText, Plus, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||
=======
|
||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, ShieldCheck, Tag, Ticket, Trophy } from "lucide-react";
|
||||
>>>>>>> aed52029c61eaa3a9376202a3d34d546a6f56b28
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { eventsApi } from "../../../services/api/events";
|
||||
|
|
@ -13,6 +17,7 @@ import { CreateFormPanel } from "../panels/CreateFormPanel";
|
|||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||
import type { Question } from "../quiz/AddQuestionModal";
|
||||
|
|
@ -215,6 +220,7 @@ export function EventHomeModules() {
|
|||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) {
|
||||
|
|
@ -870,32 +876,39 @@ export function EventHomeModules() {
|
|||
meta={webCheckInMeta}
|
||||
description="Show or hide accommodation choice for participants"
|
||||
actions={
|
||||
<>
|
||||
webCheckInEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || webCheckInLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
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
|
||||
type="button"
|
||||
disabled={actionsDisabled || webCheckInLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ModuleCard
|
||||
|
|
@ -975,6 +988,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>
|
||||
|
||||
{formEditTarget && selectedEventId ? (
|
||||
|
|
@ -1056,11 +1088,19 @@ export function EventHomeModules() {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
<<<<<<< HEAD
|
||||
{showDocumentsPanel && selectedEventId ? (
|
||||
<EventDocumentsPanel
|
||||
eventId={selectedEventId}
|
||||
eventName={eventName ?? undefined}
|
||||
onClose={() => setShowDocumentsPanel(false)}
|
||||
=======
|
||||
{staticMicrositeOpen && selectedEventId ? (
|
||||
<ManageStaticMicrositePanel
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setStaticMicrositeOpen(false)}
|
||||
onSaved={() => void loadStaticMicrositeMeta()}
|
||||
>>>>>>> aed52029c61eaa3a9376202a3d34d546a6f56b28
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1429,8 +1429,10 @@ export function AllParticipantsPage() {
|
|||
bulkDialog={bulk.bulkDialog}
|
||||
bulkBusy={bulk.bulkBusy}
|
||||
selectedCount={selectedIds.length}
|
||||
tagBulkInput={bulk.tagBulkInput}
|
||||
onTagBulkInputChange={bulk.setTagBulkInput}
|
||||
selectedTagValues={bulk.selectedTagValues}
|
||||
onToggleTag={bulk.toggleSelectedTag}
|
||||
availableTags={tagOptions}
|
||||
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||
newListName={bulk.newListName}
|
||||
onNewListNameChange={bulk.setNewListName}
|
||||
newListDesc={bulk.newListDesc}
|
||||
|
|
@ -1442,7 +1444,7 @@ export function AllParticipantsPage() {
|
|||
addToListId={bulk.addToListId}
|
||||
onAddToListIdChange={bulk.setAddToListId}
|
||||
onClose={() => bulk.setBulkDialog(null)}
|
||||
onApplyTags={(action) => void bulk.runBulkTags(action)}
|
||||
onApplyTags={(action, overrideTags) => void bulk.runBulkTags(action, overrideTags)}
|
||||
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
||||
onCreateList={() => void bulk.runBulkNewList()}
|
||||
onAddToList={() => void bulk.runBulkAddToList()}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 { useAuth } from "../../contexts/AuthContext";
|
||||
import type { Form, FormField, FormSubmissionRecord } from "../../../services/api";
|
||||
import { buildFormSubmissionsExportFilename } from "../../utils/formExportFilename";
|
||||
|
||||
|
|
@ -28,10 +29,16 @@ function participantEmail(
|
|||
return p.email?.trim() || "—";
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
function participantName(p: FormSubmissionRecord["participant"]): string {
|
||||
if (!p || typeof p === "string") return "there";
|
||||
return [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "there";
|
||||
=======
|
||||
function eventNameFromForm(form: Form | null): string {
|
||||
const ev = form?.event;
|
||||
if (ev && typeof ev === "object" && ev.name?.trim()) return ev.name.trim();
|
||||
return "Event";
|
||||
>>>>>>> aed52029c61eaa3a9376202a3d34d546a6f56b28
|
||||
}
|
||||
|
||||
type Props = {
|
||||
|
|
@ -58,6 +65,12 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
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 || [])
|
||||
.map((f: FormField) => f.label?.trim())
|
||||
|
|
@ -138,6 +151,41 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -391,6 +439,7 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
>
|
||||
<thead>
|
||||
<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" }}>Email</th>
|
||||
{tableFieldLabels.map((lab) => (
|
||||
|
|
@ -413,6 +462,29 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
const o = dataToObj(s.data);
|
||||
return (
|
||||
<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)" }}>
|
||||
{formatWhen(s.submittedAt)}
|
||||
</td>
|
||||
|
|
@ -439,6 +511,53 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
)}
|
||||
</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
|
||||
style={{
|
||||
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 { ParticipantListRecord } from "../../../services/api/participantLists";
|
||||
import { tagChipStyle } from "./participantTagUtils";
|
||||
|
||||
export type BulkDialogKind = null | "addTags" | "removeTags" | "assignEvents" | "newList" | "addToList";
|
||||
|
||||
|
|
@ -8,8 +10,10 @@ export function ParticipantBulkDialogs({
|
|||
bulkDialog,
|
||||
bulkBusy,
|
||||
selectedCount,
|
||||
tagBulkInput,
|
||||
onTagBulkInputChange,
|
||||
selectedTagValues,
|
||||
onToggleTag,
|
||||
availableTags,
|
||||
removeTagOptions,
|
||||
newListName,
|
||||
onNewListNameChange,
|
||||
newListDesc,
|
||||
|
|
@ -29,8 +33,10 @@ export function ParticipantBulkDialogs({
|
|||
bulkDialog: BulkDialogKind;
|
||||
bulkBusy: boolean;
|
||||
selectedCount: number;
|
||||
tagBulkInput: string;
|
||||
onTagBulkInputChange: (v: string) => void;
|
||||
selectedTagValues: string[];
|
||||
onToggleTag: (tag: string) => void;
|
||||
availableTags: string[];
|
||||
removeTagOptions: string[];
|
||||
newListName: string;
|
||||
onNewListNameChange: (v: string) => void;
|
||||
newListDesc: string;
|
||||
|
|
@ -42,11 +48,32 @@ export function ParticipantBulkDialogs({
|
|||
addToListId: string;
|
||||
onAddToListIdChange: (id: string) => void;
|
||||
onClose: () => void;
|
||||
onApplyTags: (action: "add_tags" | "remove_tags") => 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"
|
||||
|
|
@ -71,13 +98,110 @@ export function ParticipantBulkDialogs({
|
|||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
<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>
|
||||
<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" }} />
|
||||
<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={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} 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}
|
||||
bulkBusy={bulk.bulkBusy}
|
||||
selectedCount={selectedIds.length}
|
||||
tagBulkInput={bulk.tagBulkInput}
|
||||
onTagBulkInputChange={bulk.setTagBulkInput}
|
||||
selectedTagValues={bulk.selectedTagValues}
|
||||
onToggleTag={bulk.toggleSelectedTag}
|
||||
availableTags={tagOptions}
|
||||
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||
newListName={bulk.newListName}
|
||||
onNewListNameChange={bulk.setNewListName}
|
||||
newListDesc={bulk.newListDesc}
|
||||
|
|
@ -1106,7 +1108,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
addToListId={bulk.addToListId}
|
||||
onAddToListIdChange={bulk.setAddToListId}
|
||||
onClose={() => bulk.setBulkDialog(null)}
|
||||
onApplyTags={(action) => void bulk.runBulkTags(action)}
|
||||
onApplyTags={(action, overrideTags) => void bulk.runBulkTags(action, overrideTags)}
|
||||
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
||||
onCreateList={() => void bulk.runBulkNewList()}
|
||||
onAddToList={() => void bulk.runBulkAddToList()}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { participantListsApi } from "../../../services/api/participantLists";
|
|||
import { eventsApi } from "../../../services/api/events";
|
||||
import type { Event } from "../../../services/api/types";
|
||||
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
||||
import { parseTagsBulk } from "./participantTagUtils";
|
||||
import type { BulkDialogKind } from "./ParticipantBulkDialogs";
|
||||
|
||||
export function useParticipantBulkActions(options: {
|
||||
|
|
@ -19,7 +18,7 @@ export function useParticipantBulkActions(options: {
|
|||
|
||||
const [bulkDialog, setBulkDialog] = useState<BulkDialogKind>(null);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [tagBulkInput, setTagBulkInput] = useState("");
|
||||
const [selectedTagValues, setSelectedTagValues] = useState<string[]>([]);
|
||||
const [newListName, setNewListName] = useState("");
|
||||
const [newListDesc, setNewListDesc] = useState("");
|
||||
const [eventsPick, setEventsPick] = useState<Event[]>([]);
|
||||
|
|
@ -27,8 +26,12 @@ export function useParticipantBulkActions(options: {
|
|||
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
||||
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>) => {
|
||||
setTagBulkInput("");
|
||||
setSelectedTagValues([]);
|
||||
setNewListName("");
|
||||
setNewListDesc("");
|
||||
setAddToListId("");
|
||||
|
|
@ -63,8 +66,8 @@ export function useParticipantBulkActions(options: {
|
|||
});
|
||||
};
|
||||
|
||||
const runBulkTags = async (action: "add_tags" | "remove_tags") => {
|
||||
const tags = parseTagsBulk(tagBulkInput);
|
||||
const runBulkTags = async (action: "add_tags" | "remove_tags", overrideTags?: string[]) => {
|
||||
const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean);
|
||||
if (!tags.length) return;
|
||||
setBulkBusy(true);
|
||||
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags });
|
||||
|
|
@ -158,8 +161,8 @@ export function useParticipantBulkActions(options: {
|
|||
bulkDialog,
|
||||
setBulkDialog,
|
||||
bulkBusy,
|
||||
tagBulkInput,
|
||||
setTagBulkInput,
|
||||
selectedTagValues,
|
||||
toggleSelectedTag,
|
||||
newListName,
|
||||
setNewListName,
|
||||
newListDesc,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,15 @@ export const formsApi = {
|
|||
},
|
||||
|
||||
/** 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(
|
||||
formId: string,
|
||||
params?: { q?: string; dateFrom?: string; dateTo?: string }
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface ListMediaParams {
|
|||
page?: number;
|
||||
limit?: number;
|
||||
type?: string;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
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('limit', String(params.limit ?? 200));
|
||||
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);
|
||||
return { media: r.data?.media ?? [], total: r.data?.total ?? 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -624,6 +624,7 @@ export interface Speaker {
|
|||
export interface LibraryContent {
|
||||
_id: string;
|
||||
organization?: string;
|
||||
eventId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
visibility?: 'private' | 'public';
|
||||
|
|
@ -634,7 +635,7 @@ export interface LibraryContent {
|
|||
tags?: string[];
|
||||
collectionName?: string;
|
||||
s3Key?: string;
|
||||
uploadedBy?: string;
|
||||
uploadedBy?: string | { firstName?: string; lastName?: string; email?: string; phone?: string };
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue