2556 lines
137 KiB
TypeScript
2556 lines
137 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
import {
|
|
Palette, Mic2, Users, FileText, BookOpen, MessageSquare,
|
|
Shield, Image, Layout, Tag, Mail,
|
|
Plus, Search, X, MoreHorizontal, Upload,
|
|
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
|
|
Link2, List, Check, Share2,
|
|
} from "lucide-react";
|
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
|
import { useAuth } from "../../contexts/AuthContext";
|
|
import { useAppNav } from "../../contexts/AppNavContext";
|
|
import { tagChipStyle } from "../participants/participantTagUtils";
|
|
import { useParticipantSelection } from "../participants/useParticipantSelection";
|
|
import { useParticipantBulkActions } from "../participants/useParticipantBulkActions";
|
|
import { ParticipantTagFilters } from "../participants/ParticipantTagFilters";
|
|
import { ParticipantSelectAllMatching } from "../participants/ParticipantSelectAllMatching";
|
|
import { ParticipantBulkActionBar } from "../participants/ParticipantBulkActionBar";
|
|
import { ParticipantBulkDialogs } from "../participants/ParticipantBulkDialogs";
|
|
import {
|
|
designTemplatesApi,
|
|
speakersApi,
|
|
participantsApi,
|
|
formsApi,
|
|
usersApi,
|
|
eventsApi,
|
|
participantListsApi,
|
|
listMedia,
|
|
contentCollectionsApi,
|
|
portfolioListOrganizationId,
|
|
eventMicrositesApi,
|
|
badgeBatchesApi,
|
|
} from "../../../services/api";
|
|
import type {
|
|
DesignTemplate,
|
|
DesignTemplateType,
|
|
BadgeBatchRecord,
|
|
Speaker,
|
|
ParticipantWithEvent,
|
|
FormWithSubmissionCount,
|
|
User,
|
|
ParticipantListRecord,
|
|
ListMemberRow,
|
|
Event,
|
|
LibraryContent,
|
|
ContentCollectionRecord,
|
|
EventMicrosite,
|
|
} from "../../../services/api";
|
|
import { LoadingState } from "../shared";
|
|
import { ConfirmModal } from "../shared";
|
|
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
|
|
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
|
|
import { ImportParticipantsPanel } from "../panels/ImportParticipantsPanel";
|
|
import { EditParticipantPanel } from "../panels/EditParticipantPanel";
|
|
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
|
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
|
import { ShareFormPanel } from "../forms/ShareFormPanel";
|
|
import { usePermission } from "../../hooks/usePermission";
|
|
import { UploadFilePanel } from "../panels/UploadFilePanel";
|
|
import { CreateCollectionPanel } from "../panels/CreateCollectionPanel";
|
|
import { MessagingEmailPage } from "../messaging/MessagingEmailPage";
|
|
import { MessagingWhatsappPage } from "../messaging/MessagingWhatsappPage";
|
|
import { InviteUserPanel } from "../panels/InviteUserPanel";
|
|
import { CreateBrochurePanel } from "../panels/CreateBrochurePanel";
|
|
import {
|
|
fetchBrochurePdfContext,
|
|
buildBrochurePreviewData,
|
|
downloadBrochurePdfOffscreen,
|
|
BrochurePreviewModal,
|
|
type BrochurePdfContext,
|
|
} from "../brochure";
|
|
import { CreateMicrositePanel } from "../panels/CreateMicrositePanel";
|
|
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
|
import { CreateEmailerPanel } from "../panels/CreateEmailerPanel";
|
|
import { ImportDesignPanel } from "../panels/ImportDesignPanel";
|
|
import { IS, Label, Field, RadioGroup, focusIn, focusOut } from "../panels/PanelShell";
|
|
|
|
/* ──────────────────────────────────────
|
|
Shared helpers
|
|
────────────────────────────────────── */
|
|
const labelStyle: React.CSSProperties = {
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
color: "var(--muted)",
|
|
marginBottom: "4px",
|
|
};
|
|
|
|
/** Horizontally scrollable card for tables on small screens */
|
|
function ScrollTable({ minWidth, children }: { minWidth?: number; children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
|
<div style={{ overflowX: "auto" }}>
|
|
<div style={{ minWidth: minWidth ?? 500 }}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ icon, title, description, cta, onCta }: {
|
|
icon: React.ReactNode; title: string; description: string; cta?: string; onCta?: () => void;
|
|
}) {
|
|
return (
|
|
<div style={{ textAlign: "center", padding: "72px 20px", border: "2px dashed var(--border)", borderRadius: "var(--radius-card)" }}>
|
|
<div style={{ color: "var(--border)", marginBottom: "14px", display: "flex", justifyContent: "center" }}>{icon}</div>
|
|
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "6px" }}>{title}</p>
|
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: cta ? "20px" : "0" }}>{description}</p>
|
|
{cta && onCta && (
|
|
<button onClick={onCta}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: "6px", padding: "8px 18px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={13} /> {cta}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Design pages (dynamic: brochure, microsite, badges, emailers)
|
|
────────────────────────────────────── */
|
|
const SUBPAGE_TO_TYPE: Record<string, DesignTemplateType> = {
|
|
"design-brochure": "brochure",
|
|
"design-microsite": "microsite",
|
|
"design-badges": "badge",
|
|
"design-emailers": "emailer",
|
|
};
|
|
const templateIcons: Record<string, React.ReactNode> = {
|
|
Brochure: <Image size={22} style={{ color: "var(--muted)" }} />,
|
|
"Microsite Templates": <Layout size={22} style={{ color: "var(--muted)" }} />,
|
|
Badges: <Tag size={22} style={{ color: "var(--muted)" }} />,
|
|
Emailers: <Mail size={22} style={{ color: "var(--muted)" }} />,
|
|
};
|
|
|
|
function formatLastEdited(updatedAt?: string): string {
|
|
if (!updatedAt) return "—";
|
|
const d = new Date(updatedAt);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - d.getTime();
|
|
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
|
if (days === 0) return "Today";
|
|
if (days === 1) return "Yesterday";
|
|
if (days < 7) return `${days} days ago`;
|
|
if (days < 30) return `${Math.floor(days / 7)} week(s) ago`;
|
|
if (days < 365) return `${Math.floor(days / 30)} month(s) ago`;
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
export function DesignPage({ subPage }: { subPage?: string }) {
|
|
const { user } = useAuth();
|
|
const bp = useBreakpoint();
|
|
const isMobile = bp === "mobile";
|
|
const activeTab = subPage === "design-brochure" ? "Brochure"
|
|
: subPage === "design-microsite" ? "Microsite Templates"
|
|
: subPage === "design-badges" ? "Badges"
|
|
: subPage === "design-emailers" ? "Emailers"
|
|
: "Brochure";
|
|
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
|
|
const isSuperAdmin = user?.role === "super_admin";
|
|
const canManageMicrosite =
|
|
apiType === "microsite" &&
|
|
user &&
|
|
["super_admin", "admin", "moderator"].includes(user.role);
|
|
const canManageBadgeBatch = Boolean(user && ["super_admin", "admin", "moderator"].includes(user.role));
|
|
|
|
const [templates, setTemplates] = useState<DesignTemplate[]>([]);
|
|
const [badgeBatches, setBadgeBatches] = useState<BadgeBatchRecord[]>([]);
|
|
const [badgeBatchesRefreshing, setBadgeBatchesRefreshing] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [microEvents, setMicroEvents] = useState<Event[]>([]);
|
|
const [orgMicrosites, setOrgMicrosites] = useState<EventMicrosite[]>([]);
|
|
const [microLoading, setMicroLoading] = useState(false);
|
|
const [microError, setMicroError] = useState<string | null>(null);
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [showBrochure, setShowBrochure] = useState(false);
|
|
const [showMicrosite, setShowMicrosite] = useState(false);
|
|
const [showBadge, setShowBadge] = useState(false);
|
|
const [duplicateBadgeBatchId, setDuplicateBadgeBatchId] = useState<string | null>(null);
|
|
const [showEmailer, setShowEmailer] = useState(false);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const [editTemplateId, setEditTemplateId] = useState<string | null>(null);
|
|
const [editMicrositeEventId, setEditMicrositeEventId] = useState<string | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<DesignTemplate | null>(null);
|
|
const [brochurePdfBusyId, setBrochurePdfBusyId] = useState<string | null>(null);
|
|
const [brochurePdfError, setBrochurePdfError] = useState<string | null>(null);
|
|
const [brochurePreviewCtx, setBrochurePreviewCtx] = useState<BrochurePdfContext | null>(null);
|
|
const [emailerPreview, setEmailerPreview] = useState<{ html: string; title: string } | null>(null);
|
|
const [emailerPreviewBusyId, setEmailerPreviewBusyId] = useState<string | null>(null);
|
|
|
|
const fetchDesignList = useCallback((options?: { badgeSoftRefresh?: boolean }) => {
|
|
const badgeSoftRefresh = options?.badgeSoftRefresh === true;
|
|
if (apiType === "microsite") {
|
|
setLoading(false);
|
|
setTemplates([]);
|
|
setMicroLoading(true);
|
|
setMicroError(null);
|
|
setOrgMicrosites([]);
|
|
const orgId = portfolioListOrganizationId(user);
|
|
if (!orgId) {
|
|
setMicroEvents([]);
|
|
setMicroError(
|
|
isSuperAdmin
|
|
? "Assign an organisation to your account to load events."
|
|
: "Your profile has no organisation.",
|
|
);
|
|
setMicroLoading(false);
|
|
return;
|
|
}
|
|
Promise.all([
|
|
eventsApi.list({ limit: 100, organization: orgId }),
|
|
eventMicrositesApi.listForOrganization(orgId),
|
|
])
|
|
.then(([evRes, msRes]) => {
|
|
if (evRes.data?.events != null) {
|
|
setMicroEvents(evRes.data.events);
|
|
} else {
|
|
setMicroEvents([]);
|
|
setMicroError(evRes.error || "Failed to load events");
|
|
}
|
|
if (msRes.error || !msRes.data) {
|
|
setOrgMicrosites([]);
|
|
} else {
|
|
setOrgMicrosites(msRes.data);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setMicroEvents([]);
|
|
setOrgMicrosites([]);
|
|
setMicroError("Failed to load events");
|
|
})
|
|
.finally(() => setMicroLoading(false));
|
|
return;
|
|
}
|
|
|
|
if (apiType === "badge") {
|
|
setMicroLoading(false);
|
|
if (badgeSoftRefresh) setBadgeBatchesRefreshing(true);
|
|
else setLoading(true);
|
|
setError(null);
|
|
setTemplates([]);
|
|
const orgId = portfolioListOrganizationId(user);
|
|
if (!orgId) {
|
|
setBadgeBatches([]);
|
|
setError(
|
|
isSuperAdmin
|
|
? "Assign an organisation to your account to load badge exports."
|
|
: "Your profile has no organisation.",
|
|
);
|
|
if (badgeSoftRefresh) setBadgeBatchesRefreshing(false);
|
|
else setLoading(false);
|
|
return;
|
|
}
|
|
badgeBatchesApi
|
|
.list({ organization: orgId, limit: 100 })
|
|
.then((res) => {
|
|
if (res.data?.batches != null) setBadgeBatches(res.data.batches);
|
|
else setError(res.error || "Failed to load badge exports");
|
|
})
|
|
.catch(() => setError("Failed to load badge exports"))
|
|
.finally(() => {
|
|
if (badgeSoftRefresh) setBadgeBatchesRefreshing(false);
|
|
else setLoading(false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
setMicroLoading(false);
|
|
setLoading(true);
|
|
setError(null);
|
|
const orgId = portfolioListOrganizationId(user);
|
|
if (!orgId) {
|
|
setTemplates([]);
|
|
setError(
|
|
isSuperAdmin
|
|
? "Assign an organisation to your account (or use an org-scoped user) to load design templates."
|
|
: "Your profile has no organisation.",
|
|
);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
designTemplatesApi
|
|
.list({ type: apiType, limit: 100, organization: orgId })
|
|
.then((res) => {
|
|
if (res.data?.templates != null) setTemplates(res.data.templates);
|
|
else setError(res.error || "Failed to load templates");
|
|
})
|
|
.catch(() => setError("Failed to load templates"))
|
|
.finally(() => setLoading(false));
|
|
}, [apiType, user, isSuperAdmin]);
|
|
|
|
useEffect(() => {
|
|
fetchDesignList();
|
|
}, [fetchDesignList]);
|
|
|
|
const handleCreate = () => {
|
|
setEditTemplateId(null);
|
|
setEditMicrositeEventId(null);
|
|
if (activeTab === "Brochure") setShowBrochure(true);
|
|
else if (activeTab === "Microsite Templates") setShowMicrosite(true);
|
|
else if (activeTab === "Badges") {
|
|
setDuplicateBadgeBatchId(null);
|
|
setShowBadge(true);
|
|
}
|
|
else if (activeTab === "Emailers") setShowEmailer(true);
|
|
};
|
|
|
|
const handleEdit = (t: DesignTemplate) => {
|
|
setEditTemplateId(t._id);
|
|
if (apiType === "brochure") setShowBrochure(true);
|
|
else if (apiType === "emailer") setShowEmailer(true);
|
|
};
|
|
|
|
const handleOpenMicrositeForEvent = (ev: Event) => {
|
|
setEditMicrositeEventId(ev._id);
|
|
setShowMicrosite(true);
|
|
};
|
|
|
|
const handleClosePanel = (panel: "brochure" | "microsite" | "badge" | "emailer") => {
|
|
setEditTemplateId(null);
|
|
setEditMicrositeEventId(null);
|
|
if (panel === "brochure") setShowBrochure(false);
|
|
else if (panel === "microsite") setShowMicrosite(false);
|
|
else if (panel === "badge") {
|
|
setShowBadge(false);
|
|
setDuplicateBadgeBatchId(null);
|
|
}
|
|
else if (panel === "emailer") setShowEmailer(false);
|
|
fetchDesignList();
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!deleteConfirm) return;
|
|
const id = deleteConfirm._id;
|
|
setDeleteConfirm(null);
|
|
const res = await designTemplatesApi.remove(id);
|
|
if (!res.error) fetchDesignList();
|
|
};
|
|
|
|
const handleBrochurePdf = async (t: DesignTemplate, mode: "preview" | "download") => {
|
|
if (apiType !== "brochure") return;
|
|
setBrochurePdfBusyId(t._id);
|
|
setBrochurePdfError(null);
|
|
try {
|
|
const fresh = await designTemplatesApi.get(t._id);
|
|
if (fresh.error || !fresh.data) throw new Error(fresh.error || "Failed to load brochure");
|
|
const ctxRes = await fetchBrochurePdfContext(fresh.data);
|
|
if (!ctxRes.ok) throw new Error(ctxRes.error);
|
|
const safeName =
|
|
(fresh.data.name || "brochure").replace(/[^a-z0-9-_]+/gi, "-").replace(/^-|-$/g, "") || "brochure";
|
|
if (mode === "preview") {
|
|
setBrochurePreviewCtx(ctxRes.ctx);
|
|
} else {
|
|
const data = buildBrochurePreviewData(ctxRes.ctx);
|
|
const templateId = ctxRes.ctx.content.layoutId || "tpl1";
|
|
await downloadBrochurePdfOffscreen(templateId, data, `${safeName}.pdf`);
|
|
}
|
|
} catch (e) {
|
|
setBrochurePdfError(e instanceof Error ? e.message : "Could not generate PDF");
|
|
} finally {
|
|
setBrochurePdfBusyId(null);
|
|
}
|
|
};
|
|
|
|
const handleEmailerPreview = async (t: DesignTemplate) => {
|
|
setEmailerPreviewBusyId(t._id);
|
|
try {
|
|
const fresh = await designTemplatesApi.get(t._id);
|
|
if (fresh.error || !fresh.data) return;
|
|
const c = fresh.data.content;
|
|
let html = "";
|
|
if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") {
|
|
html = String((c as { html: string }).html || "");
|
|
}
|
|
if (!html.trim()) {
|
|
html =
|
|
'<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>';
|
|
}
|
|
setEmailerPreview({ html, title: fresh.data.name || "Email preview" });
|
|
} finally {
|
|
setEmailerPreviewBusyId(null);
|
|
}
|
|
};
|
|
|
|
const createLabel = activeTab === "Brochure" ? "Create Brochure"
|
|
: activeTab === "Badges" ? "Create Badge"
|
|
: activeTab === "Emailers" ? "Create Emailer"
|
|
: activeTab === "Microsite Templates" ? "Configure microsite"
|
|
: "Create Template";
|
|
|
|
const showDesignHeaderActions =
|
|
apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin;
|
|
|
|
function badgeBatchEventLabel(b: BadgeBatchRecord): string {
|
|
const e = b.event;
|
|
if (e && typeof e === "object" && "name" in e) return (e as { name?: string }).name || "—";
|
|
return "—";
|
|
}
|
|
|
|
const badgeListOrgId = portfolioListOrganizationId(user);
|
|
|
|
return (
|
|
<>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
<div style={{ display: "flex", alignItems: isMobile ? "stretch" : "flex-start", flexDirection: isMobile ? "column" : "row", justifyContent: "space-between", gap: "12px" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio · Design</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: isMobile ? "var(--text-xl)" : "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{activeTab}</h1>
|
|
{!loading && !error && apiType !== "microsite" && apiType !== "badge" && (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
|
{templates.length} template{templates.length !== 1 ? "s" : ""}
|
|
</p>
|
|
)}
|
|
{!loading && !error && apiType === "badge" && (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
|
{badgeBatches.length} export job{badgeBatches.length !== 1 ? "s" : ""}
|
|
</p>
|
|
)}
|
|
{!microLoading && !microError && apiType === "microsite" && (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
|
{microEvents.length} event{microEvents.length !== 1 ? "s" : ""}
|
|
{" · "}
|
|
{orgMicrosites.length} microsite template{orgMicrosites.length !== 1 ? "s" : ""}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap", justifyContent: isMobile ? "stretch" : "flex-end" }}>
|
|
{apiType === "badge" && badgeListOrgId && (
|
|
<button
|
|
type="button"
|
|
disabled={loading || badgeBatchesRefreshing}
|
|
onClick={() => void fetchDesignList({ badgeSoftRefresh: true })}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "5px",
|
|
padding: "7px 14px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
color: "var(--card-foreground)",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
cursor: loading || badgeBatchesRefreshing ? "wait" : "pointer",
|
|
opacity: loading || badgeBatchesRefreshing ? 0.65 : 1,
|
|
flex: isMobile ? 1 : "none",
|
|
justifyContent: isMobile ? "center" : "flex-start",
|
|
}}
|
|
>
|
|
<RefreshCw size={13} />
|
|
{badgeBatchesRefreshing ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
)}
|
|
{showDesignHeaderActions && (
|
|
<>
|
|
{isSuperAdmin && activeTab !== "Emailers" && activeTab !== "Badges" && (
|
|
<button
|
|
onClick={() => setShowImport(true)}
|
|
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = "0.88"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
|
|
>
|
|
<Upload size={13} /> Import
|
|
</button>
|
|
)}
|
|
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && (
|
|
<button
|
|
onClick={handleCreate}
|
|
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = "0.88"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
|
|
>
|
|
<Plus size={13} /> {isMobile ? (activeTab === "Brochure" ? "Brochure" : activeTab === "Badges" ? "Badge" : activeTab === "Emailers" ? "Emailer" : activeTab === "Microsite Templates" ? "Microsite" : "Template") : createLabel}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{loading && apiType !== "microsite" && (
|
|
<LoadingState message={apiType === "badge" ? "Loading badge exports…" : "Loading templates…"} />
|
|
)}
|
|
{microLoading && apiType === "microsite" && <LoadingState message="Loading events…" />}
|
|
{error && apiType !== "microsite" && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{error}</p>}
|
|
{brochurePdfError && apiType === "brochure" && (
|
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>
|
|
{brochurePdfError}{" "}
|
|
<button
|
|
type="button"
|
|
onClick={() => setBrochurePdfError(null)}
|
|
style={{ marginLeft: "8px", fontSize: "var(--text-xs)", textDecoration: "underline", background: "none", border: "none", cursor: "pointer", color: "inherit" }}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</p>
|
|
)}
|
|
{microError && apiType === "microsite" && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{microError}</p>}
|
|
{!loading && !error && apiType !== "microsite" && apiType !== "badge" && (
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(auto-fill, minmax(200px, 1fr))", gap: isMobile ? "10px" : "14px" }}>
|
|
{templates.map(t => (
|
|
<div key={t._id}
|
|
onMouseEnter={() => setHovered(t._id)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden", cursor: "pointer", boxShadow: hovered === t._id ? "var(--elevation-sm)" : "none", transition: "box-shadow 0.15s" }}
|
|
>
|
|
<div style={{ height: "120px", background: "var(--secondary)", display: "flex", alignItems: "center", justifyContent: "center", borderBottom: "1px solid var(--border)", position: "relative" }}>
|
|
{templateIcons[activeTab]}
|
|
{hovered === t._id && (
|
|
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
|
|
{isSuperAdmin && (
|
|
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button>
|
|
)}
|
|
{apiType === "brochure" ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={brochurePdfBusyId === t._id}
|
|
onClick={e => { e.stopPropagation(); void handleBrochurePdf(t, "preview"); }}
|
|
style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--card)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: brochurePdfBusyId === t._id ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", opacity: brochurePdfBusyId === t._id ? 0.7 : 1 }}
|
|
>
|
|
{brochurePdfBusyId === t._id ? "…" : "Preview"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={brochurePdfBusyId === t._id}
|
|
onClick={e => { e.stopPropagation(); void handleBrochurePdf(t, "download"); }}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--card)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: brochurePdfBusyId === t._id ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", opacity: brochurePdfBusyId === t._id ? 0.7 : 1 }}
|
|
>
|
|
<Download size={12} /> {brochurePdfBusyId === t._id ? "…" : "PDF"}
|
|
</button>
|
|
</>
|
|
) : apiType === "emailer" ? (
|
|
<button
|
|
type="button"
|
|
disabled={emailerPreviewBusyId === t._id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleEmailerPreview(t);
|
|
}}
|
|
style={{
|
|
padding: "5px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--card)",
|
|
color: "var(--foreground)",
|
|
border: "1px solid var(--border)",
|
|
cursor: emailerPreviewBusyId === t._id ? "wait" : "pointer",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
opacity: emailerPreviewBusyId === t._id ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{emailerPreviewBusyId === t._id ? "…" : "Preview"}
|
|
</button>
|
|
) : null}
|
|
{isSuperAdmin && (
|
|
<button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div style={{ padding: "10px 12px" }}>
|
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isSuperAdmin && (
|
|
<div
|
|
onClick={handleCreate}
|
|
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<Plus size={24} style={{ color: "var(--muted)" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{createLabel}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && apiType === "badge" && (
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(260px, 1fr))", gap: isMobile ? "10px" : "14px" }}>
|
|
{badgeBatches.map((b) => (
|
|
<div
|
|
key={b._id}
|
|
style={{
|
|
background: "var(--card)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius-card)",
|
|
overflow: "hidden",
|
|
padding: "14px 16px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "10px",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
<Tag size={20} style={{ color: "var(--muted)", flexShrink: 0 }} />
|
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{b.name}</p>
|
|
</div>
|
|
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{badgeBatchEventLabel(b)}</span>
|
|
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
Status: <strong style={{ color: "var(--foreground)" }}>{b.status}</strong>
|
|
{b.itemCount != null ? ` · ${b.itemCount} badge${b.itemCount !== 1 ? "s" : ""}` : ""}
|
|
</span>
|
|
{b.lastError && b.status === "failed" && (
|
|
<span style={{ fontSize: "10px", color: "var(--destructive)" }}>{b.lastError}</span>
|
|
)}
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginTop: "4px" }}>
|
|
{canManageBadgeBatch && (
|
|
<button
|
|
type="button"
|
|
title="Duplicate — copy settings and edit"
|
|
onClick={() => {
|
|
setDuplicateBadgeBatchId(b._id);
|
|
setShowBadge(true);
|
|
}}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
color: "var(--foreground)",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Copy size={13} /> Duplicate
|
|
</button>
|
|
)}
|
|
{b.status === "ready" && b.zipUrl ? (
|
|
<a
|
|
href={b.zipUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--primary)",
|
|
color: "var(--primary-foreground)",
|
|
textDecoration: "none",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
}}
|
|
>
|
|
<Download size={13} /> Download zip
|
|
</a>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--secondary)",
|
|
color: "var(--muted)",
|
|
border: "1px solid var(--border)",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
cursor: "not-allowed",
|
|
}}
|
|
>
|
|
<Download size={13} /> Download zip
|
|
</button>
|
|
)}
|
|
</div>
|
|
{b.createdAt && (
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Requested {formatLastEdited(b.createdAt)}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{canManageBadgeBatch && (
|
|
<div
|
|
onClick={handleCreate}
|
|
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<Plus size={24} style={{ color: "var(--muted)" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{createLabel}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!microLoading && !microError && apiType === "microsite" && (
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(240px, 1fr))", gap: isMobile ? "10px" : "14px" }}>
|
|
{microEvents.map((ev) => {
|
|
const forEvent = orgMicrosites.filter((m) => String(m.event) === String(ev._id));
|
|
const n = forEvent.length;
|
|
const folders = forEvent.map((m) => (m.publishFolder || "").trim()).filter(Boolean);
|
|
const folderLabel =
|
|
folders.length === 0 ? "—" : folders.length <= 2 ? folders.join(", ") : `${folders.slice(0, 2).join(", ")} +${folders.length - 2}`;
|
|
const liveCount = forEvent.filter((m) => m.enabled).length;
|
|
const statusLabel =
|
|
n === 0 ? "" : liveCount === n ? " · all live" : liveCount === 0 ? " · all draft" : ` · ${liveCount} live, ${n - liveCount} draft`;
|
|
return (
|
|
<div
|
|
key={ev._id}
|
|
style={{
|
|
background: "var(--card)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius-card)",
|
|
overflow: "hidden",
|
|
padding: "14px 16px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "10px",
|
|
}}
|
|
>
|
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{ev.name}</p>
|
|
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
{n === 0
|
|
? "0 microsite templates"
|
|
: `${n} microsite template${n !== 1 ? "s" : ""} · slug(s): ${folderLabel}${statusLabel}`}
|
|
</span>
|
|
{canManageMicrosite && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenMicrositeForEvent(ev)}
|
|
style={{
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--primary)",
|
|
color: "var(--primary-foreground)",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
alignSelf: "flex-start",
|
|
}}
|
|
>
|
|
Configure microsite
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{canManageMicrosite && (
|
|
<div
|
|
onClick={handleCreate}
|
|
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "120px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<Plus size={24} style={{ color: "var(--muted)" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{createLabel}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Footer />
|
|
</div>
|
|
|
|
{showBrochure && <CreateBrochurePanel templateId={editTemplateId ?? undefined} onClose={() => handleClosePanel("brochure")} onSuccess={fetchDesignList} />}
|
|
{brochurePreviewCtx && (
|
|
<BrochurePreviewModal
|
|
ctx={brochurePreviewCtx}
|
|
onClose={() => setBrochurePreviewCtx(null)}
|
|
/>
|
|
)}
|
|
{emailerPreview && (
|
|
<div
|
|
role="presentation"
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,0.55)",
|
|
zIndex: 400,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: 24,
|
|
}}
|
|
onClick={() => setEmailerPreview(null)}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-label="Email preview"
|
|
style={{
|
|
background: "var(--card)",
|
|
borderRadius: "var(--radius-card)",
|
|
width: "100%",
|
|
maxWidth: 720,
|
|
maxHeight: "90vh",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
border: "1px solid var(--border)",
|
|
boxShadow: "0 20px 40px rgba(0,0,0,0.2)",
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "12px 16px",
|
|
borderBottom: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
<span style={{ fontWeight: 600, fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>{emailerPreview.title}</span>
|
|
<button type="button" aria-label="Close" onClick={() => setEmailerPreview(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--foreground)" }}>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<iframe title="Email preview" srcDoc={emailerPreview.html} sandbox="allow-same-origin" style={{ flex: 1, minHeight: 420, border: "none", width: "100%" }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showMicrosite && (
|
|
<CreateMicrositePanel
|
|
initialEventId={editMicrositeEventId ?? undefined}
|
|
onClose={() => handleClosePanel("microsite")}
|
|
onSuccess={fetchDesignList}
|
|
/>
|
|
)}
|
|
{showBadge && (
|
|
<CreateBadgePanel
|
|
key={duplicateBadgeBatchId ?? "create"}
|
|
duplicateFromBatchId={duplicateBadgeBatchId}
|
|
onClose={() => handleClosePanel("badge")}
|
|
onSuccess={fetchDesignList}
|
|
/>
|
|
)}
|
|
{showEmailer && <CreateEmailerPanel templateId={editTemplateId ?? undefined} onClose={() => handleClosePanel("emailer")} onSuccess={fetchDesignList} />}
|
|
{showImport && <ImportDesignPanel onClose={() => setShowImport(false)} defaultType={
|
|
activeTab === "Brochure" ? "Brochure"
|
|
: activeTab === "Microsite Templates" ? "Microsite Template"
|
|
: activeTab === "Badges" ? "Badge"
|
|
: activeTab === "Emailers" ? "Emailer"
|
|
: "Other"
|
|
} />}
|
|
{deleteConfirm && (
|
|
<ConfirmModal
|
|
title="Delete template?"
|
|
message={`Delete "${deleteConfirm.name}"? This cannot be undone.`}
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={() => setDeleteConfirm(null)}
|
|
variant="danger"
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Faculty & Speakers (dynamic)
|
|
────────────────────────────────────── */
|
|
const AVATAR_COLORS = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#be185d", "#0d9488", "#b45309"];
|
|
function avatarColor(id: string): string {
|
|
let n = 0;
|
|
for (let i = 0; i < id.length; i++) n += id.charCodeAt(i);
|
|
return AVATAR_COLORS[n % AVATAR_COLORS.length];
|
|
}
|
|
|
|
export function FacultyPage() {
|
|
const { user } = useAuth();
|
|
const { selectedEventId } = useAppNav();
|
|
const [speakers, setSpeakers] = useState<Speaker[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [focused, setFocused] = useState(false);
|
|
const [showAddSpeaker, setShowAddSpeaker] = useState(false);
|
|
const [editSpeakerId, setEditSpeakerId] = useState<string | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<Speaker | null>(null);
|
|
|
|
const isSuperAdmin = user?.role === "super_admin";
|
|
const userOrgId =
|
|
user?.organization && typeof user.organization === "object" && "_id" in user.organization
|
|
? String((user.organization as { _id: string })._id)
|
|
: user?.organization
|
|
? String(user.organization)
|
|
: undefined;
|
|
|
|
const fetchSpeakers = useCallback(() => {
|
|
setLoading(true);
|
|
setError(null);
|
|
if (!userOrgId) {
|
|
setError(
|
|
isSuperAdmin
|
|
? "Your profile has no organisation — assign one in settings or add ?organization= to the API. Faculty list is scoped by organisation."
|
|
: "Your profile has no organisation.",
|
|
);
|
|
setSpeakers([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
speakersApi.list({ limit: 200, organization: userOrgId })
|
|
.then(res => {
|
|
if (res.data?.speakers != null) setSpeakers(res.data.speakers);
|
|
else setError(res.error ?? "Failed to load speakers");
|
|
})
|
|
.catch(() => setError("Failed to load speakers"))
|
|
.finally(() => setLoading(false));
|
|
}, [isSuperAdmin, userOrgId]);
|
|
|
|
useEffect(() => { fetchSpeakers(); }, [fetchSpeakers]);
|
|
|
|
const filtered = speakers.filter(s => {
|
|
const name = `${s.firstName} ${s.lastName}`.toLowerCase();
|
|
const title = (s.title ?? "").toLowerCase();
|
|
const org = (s.organizationName ?? "").toLowerCase();
|
|
const q = search.toLowerCase();
|
|
return !q || name.includes(q) || title.includes(q) || org.includes(q);
|
|
});
|
|
|
|
const handleEdit = (s: Speaker) => {
|
|
setEditSpeakerId(s._id);
|
|
setShowAddSpeaker(true);
|
|
};
|
|
|
|
const handleClosePanel = () => {
|
|
setEditSpeakerId(null);
|
|
setShowAddSpeaker(false);
|
|
fetchSpeakers();
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!deleteConfirm) return;
|
|
const id = deleteConfirm._id;
|
|
setDeleteConfirm(null);
|
|
const res = await speakersApi.remove(id);
|
|
if (!res.error) fetchSpeakers();
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Faculty & Speakers</h1>
|
|
{!loading && !error && <p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{speakers.length} speaker{speakers.length !== 1 ? "s" : ""} across all events</p>}
|
|
</div>
|
|
{isSuperAdmin && (
|
|
<button
|
|
onClick={() => { setEditSpeakerId(null); setShowAddSpeaker(true); }}
|
|
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={13} /> Add Speaker
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "0 10px", height: "36px", maxWidth: "320px", background: "var(--card)", border: `1px solid ${focused ? "var(--ring)" : "var(--border)"}`, borderRadius: "var(--radius-button)", transition: "border-color 0.12s" }}>
|
|
<Search size={13} style={{ color: "var(--muted)", flexShrink: 0 }} />
|
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search speakers…"
|
|
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
|
|
style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)" }} />
|
|
{search && <button onClick={() => setSearch("")} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: 0 }}><X size={12} /></button>}
|
|
</div>
|
|
|
|
{loading && <LoadingState message="Loading speakers…" />}
|
|
{error && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{error}</p>}
|
|
{!loading && !error && (
|
|
<ScrollTable>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 80px 60px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
|
{["Speaker", "Title", "Organisation", "Sessions", ""].map(h => (
|
|
<span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>
|
|
))}
|
|
</div>
|
|
{filtered.map(s => {
|
|
const name = `${s.firstName} ${s.lastName}`.trim() || "—";
|
|
const sessionsCount = s.sessions?.length ?? 0;
|
|
const initials = name.split(/\s+/).map(n => n[0]).join("").slice(0, 2).toUpperCase() || "?";
|
|
return (
|
|
<div key={s._id}
|
|
onMouseEnter={() => setHovered(s._id)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 80px 60px", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === s._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
|
<div style={{ width: "32px", height: "32px", borderRadius: "50%", background: avatarColor(s._id), display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span>
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{name}</span>
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{s.title ?? "—"}</span>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{s.organizationName ?? "—"}</span>
|
|
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: "var(--accent)", color: "var(--primary)", fontSize: "11px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{sessionsCount} {sessionsCount === 1 ? "session" : "sessions"}</span>
|
|
<div style={{ display: "flex", gap: "4px", opacity: hovered === s._id ? 1 : 0.3, transition: "opacity 0.15s" }}>
|
|
{isSuperAdmin && (
|
|
<>
|
|
<button title="Edit speaker" onClick={() => handleEdit(s)} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<Pencil size={11} />
|
|
</button>
|
|
<button title="Delete speaker" onClick={() => setDeleteConfirm(s)} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{filtered.length === 0 && (
|
|
<div style={{ padding: "48px 20px", textAlign: "center" }}>
|
|
<Search size={24} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>No speakers found</p>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>{search ? `No results for "${search}". Try a different name or title.` : "Add a speaker to get started."}</p>
|
|
{search && (
|
|
<button onClick={() => setSearch("")}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: "5px", padding: "5px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", color: "var(--card-foreground)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
|
|
<X size={11} /> Clear search
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</ScrollTable>
|
|
)}
|
|
<Footer />
|
|
{showAddSpeaker && (
|
|
<AddSpeakerPanel
|
|
speakerId={editSpeakerId ?? undefined}
|
|
eventId={selectedEventId}
|
|
onClose={handleClosePanel}
|
|
onSuccess={fetchSpeakers}
|
|
/>
|
|
)}
|
|
{deleteConfirm && (
|
|
<ConfirmModal
|
|
title="Delete speaker?"
|
|
message={`Remove "${deleteConfirm.firstName} ${deleteConfirm.lastName}"? This cannot be undone.`}
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={() => setDeleteConfirm(null)}
|
|
variant="danger"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
All Participants (Global)
|
|
────────────────────────────────────── */
|
|
function toAllParticipantRow(p: ParticipantWithEvent) {
|
|
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
|
|
const statusRaw = p.status;
|
|
const statusLabel =
|
|
statusRaw == null || statusRaw === ""
|
|
? "—"
|
|
: statusRaw === "cancelled"
|
|
? "Cancelled"
|
|
: statusRaw === "checked_in"
|
|
? "Checked In"
|
|
: statusRaw === "confirmed"
|
|
? "Confirmed"
|
|
: "Pending";
|
|
const ev = p.event as { _id?: string; name?: string } | undefined;
|
|
const eventName = ev?.name ?? "—";
|
|
const id = String(p._id);
|
|
return { rowKey: id, id, name, email: p.email ?? "", tags: p.tags ?? [], eventName, status: statusLabel };
|
|
}
|
|
|
|
export function AllParticipantsPage() {
|
|
const { user } = useAuth();
|
|
const listOrgId = portfolioListOrganizationId(user);
|
|
const isSuperAdmin = user?.role === "super_admin";
|
|
const { setActiveNav } = useAppNav();
|
|
const [list, setList] = useState<ReturnType<typeof toAllParticipantRow>[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [limit] = useState(50);
|
|
const [searchInput, setSearchInput] = useState("");
|
|
const [search, setSearch] = useState("");
|
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [focused, setFocused] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCustomList, setShowCustomList] = useState(false);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const [importMsg, setImportMsg] = useState<string | null>(null);
|
|
const [listNonce, setListNonce] = useState(0);
|
|
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
|
const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
|
const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
|
|
const {
|
|
selectedIds,
|
|
setSelectedIds,
|
|
selectAllMatchingActive,
|
|
setSelectAllMatchingActive,
|
|
headerSelectRef,
|
|
toggleRow,
|
|
toggleAllVisible,
|
|
clearSelection,
|
|
} = useParticipantSelection(
|
|
list.map((p) => p.id),
|
|
[search, activeTag]
|
|
);
|
|
|
|
const refreshList = useCallback(() => setListNonce((n) => n + 1), []);
|
|
|
|
const bulk = useParticipantBulkActions({
|
|
selectedIds,
|
|
listOrgId,
|
|
onMessage: setImportMsg,
|
|
onClearSelection: clearSelection,
|
|
onRefresh: refreshList,
|
|
});
|
|
|
|
useEffect(() => {
|
|
searchDebounce.current = setTimeout(() => setSearch(searchInput.trim()), 300);
|
|
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
|
}, [searchInput]);
|
|
|
|
useEffect(() => { setPage(1); }, [search, activeTag]);
|
|
|
|
useEffect(() => {
|
|
if (!listOrgId) {
|
|
setTagOptions([]);
|
|
return;
|
|
}
|
|
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
|
|
if (r.data?.tags?.length) setTagOptions(r.data.tags);
|
|
});
|
|
}, [listOrgId, listNonce]);
|
|
|
|
useEffect(() => {
|
|
if (!listOrgId) {
|
|
setList([]);
|
|
setTotal(0);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
participantsApi
|
|
.listAll({
|
|
organization: listOrgId,
|
|
page,
|
|
limit,
|
|
q: search || undefined,
|
|
tag: activeTag ?? undefined,
|
|
})
|
|
.then((res) => {
|
|
setLoading(false);
|
|
if (res.data?.participants) {
|
|
setList((res.data.participants as ParticipantWithEvent[]).map(toAllParticipantRow));
|
|
setTotal(res.data.total ?? 0);
|
|
}
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, [listOrgId, page, limit, search, activeTag, listNonce]);
|
|
|
|
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
|
|
|
|
const runExportAll = async () => {
|
|
if (exporting) return;
|
|
setExporting(true);
|
|
if (!listOrgId) return;
|
|
await participantsApi.downloadExportAll({
|
|
organization: listOrgId,
|
|
q: search || undefined,
|
|
tag: activeTag ?? undefined,
|
|
});
|
|
setExporting(false);
|
|
};
|
|
|
|
const selectAllMatchingSearch = async () => {
|
|
if (!listOrgId) {
|
|
setImportMsg(isSuperAdmin ? "Organisation required for this action." : "Your profile has no organisation.");
|
|
setSelectAllMatchingActive(false);
|
|
return;
|
|
}
|
|
const r = await participantsApi.matchingIds({
|
|
organization: listOrgId,
|
|
q: search || undefined,
|
|
tag: activeTag ?? undefined,
|
|
});
|
|
if (r.error) {
|
|
setImportMsg(r.error);
|
|
setSelectAllMatchingActive(false);
|
|
return;
|
|
}
|
|
const ids = r.data?.ids ?? [];
|
|
if (r.data?.capped) setImportMsg(`Note: only the first ${ids.length} matching people are selected (limit). Refine filters if needed.`);
|
|
setSelectedIds(ids);
|
|
setSelectAllMatchingActive(ids.length > 0);
|
|
};
|
|
|
|
const confirmDeleteOne = async () => {
|
|
if (!deleteTarget) return;
|
|
const { id, name } = deleteTarget;
|
|
setDeleteTarget(null);
|
|
const res = await participantsApi.deleteOne(id);
|
|
if (res.error) {
|
|
setImportMsg(res.error);
|
|
return;
|
|
}
|
|
setImportMsg(`Removed ${name}.`);
|
|
setSelectAllMatchingActive(false);
|
|
setSelectedIds((prev) => prev.filter((x) => x !== id));
|
|
setListNonce((n) => n + 1);
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
{importMsg && (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
<span>{importMsg}</span>
|
|
<button type="button" onClick={() => setImportMsg(null)} style={{ background: "none", border: "none", cursor: "pointer" }}><X size={16} /></button>
|
|
</div>
|
|
)}
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: "12px" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
|
{total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))}
|
|
</p>
|
|
</div>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
|
<button type="button" disabled={exporting} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting ? 0.7 : 1 }}>
|
|
<Download size={13} /> {exporting ? "Exporting…" : "Export"}
|
|
</button>
|
|
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
|
<Upload size={13} /> Import
|
|
</button>
|
|
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
|
Custom lists
|
|
</button>
|
|
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={13} /> Create Custom List
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter row */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
|
<ParticipantSelectAllMatching
|
|
total={total}
|
|
loading={loading}
|
|
busy={bulk.bulkBusy}
|
|
checked={selectAllMatchingActive}
|
|
onChange={(checked) => {
|
|
if (checked) void selectAllMatchingSearch();
|
|
else clearSelection();
|
|
}}
|
|
/>
|
|
{/* Search */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: "7px", padding: "0 10px", height: "34px", background: "var(--card)", border: `1px solid ${focused ? "var(--ring)" : "var(--border)"}`, borderRadius: "var(--radius-button)", maxWidth: "280px", flex: "1 1 200px", transition: "border-color 0.12s" }}>
|
|
<Search size={12} style={{ color: "var(--muted)", flexShrink: 0 }} />
|
|
<input value={searchInput} onChange={e => setSearchInput(e.target.value)} placeholder="Search participants…"
|
|
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
|
|
style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)" }} />
|
|
{searchInput && <button type="button" onClick={() => setSearchInput("")} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: 0 }}><X size={11} /></button>}
|
|
</div>
|
|
{/* Tag filters */}
|
|
{tagOptions.length === 0 ? (
|
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>No tags yet — add tags when editing or importing.</span>
|
|
) : (
|
|
<ParticipantTagFilters tagOptions={tagOptions} activeTag={activeTag} onChange={setActiveTag} />
|
|
)}
|
|
</div>
|
|
|
|
<ParticipantBulkActionBar
|
|
selectedCount={selectedIds.length}
|
|
busy={bulk.bulkBusy}
|
|
variant="portfolio"
|
|
onAddTags={() => void bulk.openBulkDialog("addTags")}
|
|
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
|
|
onAssignEvents={() => void bulk.openBulkDialog("assignEvents")}
|
|
onNewList={() => void bulk.openBulkDialog("newList")}
|
|
onAddToList={() => void bulk.openBulkDialog("addToList")}
|
|
onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }}
|
|
onClearSelection={clearSelection}
|
|
/>
|
|
|
|
{/* Table */}
|
|
<ScrollTable minWidth={720}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
|
|
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Event</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Actions</span>
|
|
</div>
|
|
{loading ? (
|
|
<div style={{ padding: "40px", textAlign: "center", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Loading…</div>
|
|
) : list.length === 0 ? (
|
|
<div style={{ padding: "40px", textAlign: "center" }}>
|
|
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
|
|
No participants found
|
|
</p>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
|
|
{(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
|
|
</p>
|
|
{(search || activeTag) && (
|
|
<button type="button" onClick={clearFilters}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: "5px", padding: "5px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", color: "var(--card-foreground)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
|
|
<X size={11} /> Clear filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : list.map(p => (
|
|
<div key={p.rowKey}
|
|
onMouseEnter={() => setHovered(p.rowKey)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
|
>
|
|
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
|
|
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
|
|
</div>
|
|
<div style={{ minWidth: 0 }}>
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
|
|
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
|
|
</div>
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span>
|
|
<div style={{ display: "flex", gap: "3px", flexWrap: "wrap" }}>
|
|
{p.tags.map(tag => {
|
|
const tc = tagChipStyle(tag);
|
|
return <span key={tag} style={{ padding: "1px 6px", borderRadius: "999px", background: tc.bg, color: tc.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{tag}</span>;
|
|
})}
|
|
{p.tags.length === 0 && <span style={{ fontSize: "10px", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>—</span>}
|
|
</div>
|
|
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
|
|
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
|
|
<Pencil size={13} />
|
|
</button>
|
|
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</ScrollTable>
|
|
{!loading && total > limit && (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", padding: "8px 0" }}>
|
|
<button
|
|
type="button"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
cursor: page <= 1 ? "not-allowed" : "pointer",
|
|
opacity: page <= 1 ? 0.5 : 1,
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
}}
|
|
>
|
|
<ChevronLeft size={16} /> Previous
|
|
</button>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
{page} / {Math.max(1, Math.ceil(total / limit))}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={page >= Math.ceil(total / limit)}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
cursor: page >= Math.ceil(total / limit) ? "not-allowed" : "pointer",
|
|
opacity: page >= Math.ceil(total / limit) ? 0.5 : 1,
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
}}
|
|
>
|
|
Next <ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{editParticipantId && (
|
|
<EditParticipantPanel
|
|
participantId={editParticipantId}
|
|
onClose={() => setEditParticipantId(null)}
|
|
onSaved={() => {
|
|
setListNonce((n) => n + 1);
|
|
if (!listOrgId) return;
|
|
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
|
|
if (r.data?.tags?.length) setTagOptions(r.data.tags);
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
{deleteTarget && (
|
|
<ConfirmModal
|
|
title="Delete participant?"
|
|
message={`Remove ${deleteTarget.name} from your organization? This cannot be undone.`}
|
|
confirmLabel="Delete"
|
|
onConfirm={() => void confirmDeleteOne()}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
variant="danger"
|
|
/>
|
|
)}
|
|
<ParticipantBulkDialogs
|
|
bulkDialog={bulk.bulkDialog}
|
|
bulkBusy={bulk.bulkBusy}
|
|
selectedCount={selectedIds.length}
|
|
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}
|
|
onNewListDescChange={bulk.setNewListDesc}
|
|
eventsPick={bulk.eventsPick}
|
|
eventPickIds={bulk.eventPickIds}
|
|
onToggleEventPick={bulk.toggleEventPick}
|
|
fixedLists={bulk.fixedLists}
|
|
addToListId={bulk.addToListId}
|
|
onAddToListIdChange={bulk.setAddToListId}
|
|
onClose={() => bulk.setBulkDialog(null)}
|
|
onApplyTags={(action, overrideTags) => void bulk.runBulkTags(action, overrideTags)}
|
|
onAssignEvents={() => void bulk.runBulkAssignEvents()}
|
|
onCreateList={() => void bulk.runBulkNewList()}
|
|
onAddToList={() => void bulk.runBulkAddToList()}
|
|
/>
|
|
<Footer />
|
|
{showImport && (
|
|
<ImportParticipantsPanel
|
|
onClose={() => setShowImport(false)}
|
|
onSuccess={(msg) => {
|
|
setImportMsg(msg);
|
|
setListNonce((n) => n + 1);
|
|
}}
|
|
/>
|
|
)}
|
|
{showCustomList && (
|
|
<CreateCustomListPanel
|
|
onClose={() => setShowCustomList(false)}
|
|
onCreated={() => setListNonce((n) => n + 1)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatListMemberStatus(s: string | undefined): string {
|
|
if (s == null || s === "") return "—";
|
|
const raw = String(s).toLowerCase();
|
|
if (raw === "cancelled" || raw === "canceled") return "Cancelled";
|
|
if (raw === "checked_in") return "Checked In";
|
|
if (raw === "confirmed") return "Confirmed";
|
|
if (raw === "pending") return "Pending";
|
|
return String(s);
|
|
}
|
|
|
|
function formatListDate(iso: string | undefined): string {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "—";
|
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Participant lists (saved / rule-based)
|
|
────────────────────────────────────── */
|
|
export function ParticipantListsPage() {
|
|
const { user } = useAuth();
|
|
const listOrgId = portfolioListOrganizationId(user);
|
|
const { setActiveNav } = useAppNav();
|
|
const [lists, setLists] = useState<ParticipantListRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [ruleListBeingEdited, setRuleListBeingEdited] = useState<ParticipantListRecord | null>(null);
|
|
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
|
const [viewList, setViewList] = useState<ParticipantListRecord | null>(null);
|
|
const [mem, setMem] = useState<ListMemberRow[]>([]);
|
|
const [mloading, setMloading] = useState(false);
|
|
const [memPage, setMemPage] = useState(1);
|
|
const [memTotal, setMemTotal] = useState(0);
|
|
const memLimit = 50;
|
|
const [editTarget, setEditTarget] = useState<ParticipantListRecord | null>(null);
|
|
const [editName, setEditName] = useState("");
|
|
const [editDesc, setEditDesc] = useState("");
|
|
const [editVisibility, setEditVisibility] = useState<"Private" | "Shared">("Private");
|
|
const [editSaving, setEditSaving] = useState(false);
|
|
const [editErr, setEditErr] = useState<string | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
|
const [pageMsg, setPageMsg] = useState<string | null>(null);
|
|
|
|
const load = useCallback(() => {
|
|
setLoading(true);
|
|
if (!listOrgId) {
|
|
setLists([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
participantListsApi.list({ organization: listOrgId }).then((r) => {
|
|
setLoading(false);
|
|
if (r.data?.lists) setLists(r.data.lists);
|
|
}).catch(() => setLoading(false));
|
|
}, [listOrgId]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (!viewList) {
|
|
setMem([]);
|
|
setMemTotal(0);
|
|
setMemPage(1);
|
|
return;
|
|
}
|
|
if (!listOrgId) {
|
|
setMem([]);
|
|
setMemTotal(0);
|
|
return;
|
|
}
|
|
setMloading(true);
|
|
participantListsApi
|
|
.members(viewList._id, { organization: listOrgId, page: memPage, limit: memLimit })
|
|
.then((r) => {
|
|
setMloading(false);
|
|
if (r.data?.members) setMem(r.data.members);
|
|
setMemTotal(r.data?.total ?? 0);
|
|
})
|
|
.catch(() => setMloading(false));
|
|
}, [viewList?._id, memPage, listOrgId]);
|
|
|
|
const memPageCount = Math.max(1, Math.ceil(memTotal / memLimit));
|
|
|
|
const openEdit = (L: ParticipantListRecord) => {
|
|
if (L.type === "rule_based") {
|
|
setPageMsg(null);
|
|
setRuleListBeingEdited(L);
|
|
setShowCreate(true);
|
|
return;
|
|
}
|
|
setEditErr(null);
|
|
setEditTarget(L);
|
|
setEditName(L.name);
|
|
setEditDesc(L.description ?? "");
|
|
setEditVisibility(L.visibility === "shared" ? "Shared" : "Private");
|
|
};
|
|
|
|
const closeEdit = () => {
|
|
setEditTarget(null);
|
|
setEditErr(null);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (!editTarget || !editName.trim()) {
|
|
setEditErr("Name is required.");
|
|
return;
|
|
}
|
|
setEditSaving(true);
|
|
setEditErr(null);
|
|
const payload: Parameters<typeof participantListsApi.update>[1] = {
|
|
name: editName.trim(),
|
|
description: editDesc.trim(),
|
|
visibility: editVisibility === "Shared" ? "shared" : "private",
|
|
};
|
|
const res = await participantListsApi.update(editTarget._id, payload);
|
|
setEditSaving(false);
|
|
if (res.error) {
|
|
setEditErr(res.error);
|
|
return;
|
|
}
|
|
setPageMsg(`Updated “${editName.trim()}”.`);
|
|
closeEdit();
|
|
load();
|
|
if (viewList?._id === editTarget._id && res.data) {
|
|
setViewList(res.data as ParticipantListRecord);
|
|
}
|
|
};
|
|
|
|
const runDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
const { id, name } = deleteTarget;
|
|
setDeleteTarget(null);
|
|
const res = await participantListsApi.remove(id);
|
|
if (res.error) {
|
|
setPageMsg(res.error);
|
|
return;
|
|
}
|
|
setPageMsg(`Deleted “${name}”.`);
|
|
if (viewList?._id === id) setViewList(null);
|
|
load();
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
{pageMsg && (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
<span>{pageMsg}</span>
|
|
<button type="button" onClick={() => setPageMsg(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)" }}><X size={16} /></button>
|
|
</div>
|
|
)}
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: "12px" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participant lists</h1>
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
|
Fixed lists and rule-based lists. Open a list to see who is included.
|
|
</p>
|
|
</div>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
|
<button type="button" onClick={() => { setPageMsg(null); setActiveNav("all-participants"); }} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
All participants
|
|
</button>
|
|
<button type="button" onClick={() => { setPageMsg(null); setRuleListBeingEdited(null); setShowCreate(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={13} /> Create Custom List
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollTable minWidth={780}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "minmax(160px,1.4fr) 110px 88px 100px minmax(260px,1fr)", padding: "10px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center", gap: "8px" }}>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>List</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Type</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Visibility</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Updated</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", textAlign: "right" }}>Actions</span>
|
|
</div>
|
|
{loading ? (
|
|
<div style={{ padding: "40px", textAlign: "center", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Loading…</div>
|
|
) : lists.length === 0 ? (
|
|
<div style={{ padding: "48px 20px", textAlign: "center" }}>
|
|
<Users size={28} style={{ color: "var(--border)", margin: "0 auto 12px" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "6px" }}>No lists yet</p>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "16px" }}>Create a custom list from here or from All participants.</p>
|
|
<button type="button" onClick={() => { setRuleListBeingEdited(null); setShowCreate(true); }} style={{ display: "inline-flex", alignItems: "center", gap: "6px", padding: "8px 18px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={14} /> Create Custom List
|
|
</button>
|
|
</div>
|
|
) : (
|
|
lists.map((L) => (
|
|
<div
|
|
key={L._id}
|
|
onMouseEnter={() => setHoveredRow(L._id)}
|
|
onMouseLeave={() => setHoveredRow(null)}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "minmax(160px,1.4fr) 110px 88px 100px minmax(260px,1fr)",
|
|
padding: "12px 16px",
|
|
borderBottom: "1px solid var(--border)",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
background: hoveredRow === L._id ? "var(--secondary)" : "var(--card)",
|
|
transition: "background 0.12s",
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0 }}>
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{L.name}</p>
|
|
{L.description ? (
|
|
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "4px 0 0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{L.description}</p>
|
|
) : null}
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{L.type === "rule_based" ? "Rule-based" : "Fixed"}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{L.visibility === "shared" ? "Shared" : "Private"}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatListDate(L.updatedAt)}</span>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "6px", flexWrap: "wrap" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setMemPage(1); setViewList(L); }}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "5px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--primary)", background: "transparent", color: "var(--primary)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}
|
|
>
|
|
<Users size={12} /> View participants
|
|
</button>
|
|
<button type="button" title="Edit list" onClick={() => openEdit(L)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
|
|
<Pencil size={13} />
|
|
</button>
|
|
<button type="button" title="Delete list" onClick={() => setDeleteTarget({ id: L._id, name: L.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</ScrollTable>
|
|
|
|
{viewList && (
|
|
<>
|
|
<div onClick={() => setViewList(null)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.50)", zIndex: 200 }} />
|
|
<div style={{ position: "fixed", top: 0, right: 0, height: "100%", width: "min(520px,100vw)", background: "var(--card)", zIndex: 201, display: "flex", flexDirection: "column", boxShadow: "-4px 0 40px rgba(0,0,0,0.20)" }}>
|
|
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)", flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 4px" }}>Participants in list</p>
|
|
<h2 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-lg)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{viewList.name}</h2>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "6px 0 0" }}>
|
|
{viewList.type === "rule_based" ? "Rule-based" : "Fixed"} · {memTotal} {memTotal === 1 ? "person" : "people"}
|
|
</p>
|
|
</div>
|
|
<button type="button" onClick={() => setViewList(null)} title="Close" style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", color: "var(--muted)" }}>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
|
{mloading ? (
|
|
<p style={{ padding: "24px", fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Loading…</p>
|
|
) : (
|
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
<thead>
|
|
<tr style={{ background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
|
{["Name", "Email", "Organization", "Status"].map((h) => (
|
|
<th key={h} style={{ textAlign: "left", padding: "10px 14px", fontWeight: "600", color: "var(--muted)", fontSize: "10px", textTransform: "uppercase" }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{mem.map((m, i) => (
|
|
<tr key={`${m.participantId ?? "s"}-${i}`} style={{ borderBottom: "1px solid var(--border)" }}>
|
|
<td style={{ padding: "10px 14px" }}>{m.name}</td>
|
|
<td style={{ padding: "10px 14px", color: "var(--muted)", wordBreak: "break-word" }}>{m.email || "—"}</td>
|
|
<td style={{ padding: "10px 14px", color: "var(--muted)" }}>{m.organization || "—"}</td>
|
|
<td style={{ padding: "10px 14px" }}>{formatListMemberStatus(m.status)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
{!mloading && mem.length === 0 && (
|
|
<p style={{ padding: "20px", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>No participants in this list.</p>
|
|
)}
|
|
</div>
|
|
{viewList && !mloading && memTotal > memLimit && (
|
|
<div style={{ flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", padding: "14px 16px", borderTop: "1px solid var(--border)" }}>
|
|
<button type="button" disabled={memPage <= 1} onClick={() => setMemPage((p) => Math.max(1, p - 1))} style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: memPage <= 1 ? "not-allowed" : "pointer", opacity: memPage <= 1 ? 0.5 : 1, fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
<ChevronLeft size={16} /> Previous
|
|
</button>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
{memPage} / {memPageCount} · {memTotal} total
|
|
</span>
|
|
<button type="button" disabled={memPage >= memPageCount} onClick={() => setMemPage((p) => p + 1)} style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: memPage >= memPageCount ? "not-allowed" : "pointer", opacity: memPage >= memPageCount ? 0.5 : 1, fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
Next <ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{editTarget && (
|
|
<div role="dialog" aria-modal="true" style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 210, display: "flex", alignItems: "center", justifyContent: "center", padding: "16px" }} onClick={closeEdit}>
|
|
<div style={{ width: "100%", maxWidth: "420px", background: "var(--card)", borderRadius: "var(--radius-card)", border: "1px solid var(--border)", boxShadow: "var(--shadow-xl)", padding: "22px" }} onClick={(e) => e.stopPropagation()}>
|
|
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-lg)", fontWeight: "var(--font-weight-medium)", margin: "0 0 16px", color: "var(--foreground)" }}>Edit list</h3>
|
|
{editErr && <p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", fontFamily: "var(--font-noto-sans)", margin: "0 0 12px" }}>{editErr}</p>}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
|
<Field>
|
|
<Label text="Name" required />
|
|
<input style={IS} value={editName} onChange={(e) => setEditName(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
|
</Field>
|
|
<Field>
|
|
<Label text="Description" />
|
|
<textarea style={{ ...IS, resize: "vertical", minHeight: "72px" }} value={editDesc} onChange={(e) => setEditDesc(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
|
</Field>
|
|
<Field>
|
|
<Label text="Visibility" required />
|
|
<RadioGroup name="plEditVis" value={editVisibility} onChange={(v) => setEditVisibility(v as "Private" | "Shared")} options={[{ label: "Private", value: "Private" }, { label: "Shared", value: "Shared" }]} />
|
|
</Field>
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px", marginTop: "22px", paddingTop: "16px", borderTop: "1px solid var(--border)" }}>
|
|
<button type="button" onClick={closeEdit} disabled={editSaving} style={{ padding: "7px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: editSaving ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>Cancel</button>
|
|
<button type="button" onClick={() => void saveEdit()} disabled={editSaving} style={{ padding: "7px 16px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: editSaving ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>{editSaving ? "Saving…" : "Save"}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{deleteTarget && (
|
|
<ConfirmModal
|
|
title="Delete list"
|
|
message={`Delete “${deleteTarget.name}”? Participants in your database are not removed—only this list is deleted.`}
|
|
confirmLabel="Delete"
|
|
variant="danger"
|
|
onConfirm={() => void runDelete()}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<CreateCustomListPanel
|
|
key={ruleListBeingEdited?._id ?? "new-list-panel"}
|
|
editList={ruleListBeingEdited}
|
|
onClose={() => { setShowCreate(false); setRuleListBeingEdited(null); }}
|
|
onCreated={() => { load(); setPageMsg("Custom list created."); }}
|
|
onSaved={(doc) => {
|
|
load();
|
|
setPageMsg(`Updated “${doc.name}”.`);
|
|
if (viewList?._id === doc._id) setViewList(doc);
|
|
}}
|
|
/>
|
|
)}
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Forms & Workflow
|
|
────────────────────────────────────── */
|
|
function formDisplayType(apiType: string): string {
|
|
if (apiType === "feedback") return "Feedback";
|
|
if (apiType === "registration") return "Registration";
|
|
if (apiType === "other") return "Other";
|
|
if (apiType === "survey" || apiType === "custom") return "Other";
|
|
return apiType;
|
|
}
|
|
|
|
function formEventName(f: FormWithSubmissionCount): string {
|
|
const ev = f.event;
|
|
if (!ev) return "—";
|
|
if (typeof ev === "string") return "—";
|
|
return (ev as { name?: string }).name?.trim() || "—";
|
|
}
|
|
|
|
function formEventId(f: FormWithSubmissionCount): string | null {
|
|
const ev = f.event;
|
|
if (!ev) return null;
|
|
if (typeof ev === "string") return ev.trim().length === 24 ? ev.trim() : null;
|
|
const id = (ev as { _id?: string })._id;
|
|
return id && String(id).trim().length === 24 ? String(id).trim() : null;
|
|
}
|
|
|
|
/** New form create must not send Mongo subdoc _id on fields (Joi / duplicate creation). */
|
|
function cloneFormFieldsForDuplicate(fields: unknown): unknown[] | undefined {
|
|
if (!Array.isArray(fields)) return undefined;
|
|
return fields.map((row) => {
|
|
if (!row || typeof row !== "object") return row;
|
|
const { _id, __v, ...rest } = row as Record<string, unknown>;
|
|
return rest;
|
|
});
|
|
}
|
|
|
|
function cloneSettingsForDuplicate(settings: unknown): unknown {
|
|
if (!settings || typeof settings !== "object") return settings;
|
|
const { _id, __v, ...rest } = settings as Record<string, unknown>;
|
|
return rest;
|
|
}
|
|
|
|
export function FormsPage({ subPage }: { subPage?: string }) {
|
|
const { user } = useAuth();
|
|
const { canModule } = usePermission();
|
|
const orgId = portfolioListOrganizationId(user);
|
|
const canReadForms = canModule("forms", "read");
|
|
const canWriteForms = canModule("forms", "write");
|
|
const canDeleteForms = canModule("forms", "delete");
|
|
const filterType = subPage === "forms-feedback" ? "Feedback"
|
|
: subPage === "forms-registration" ? "Registration"
|
|
: subPage === "forms-other" ? "Other"
|
|
: null;
|
|
const [forms, setForms] = useState<FormWithSubmissionCount[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [showFeedback, setShowFeedback] = useState(false);
|
|
const [showReg, setShowReg] = useState(false);
|
|
const [showOther, setShowOther] = useState(false);
|
|
const [editFormId, setEditFormId] = useState<string | null>(null);
|
|
const [formDeleteTarget, setFormDeleteTarget] = useState<FormWithSubmissionCount | null>(null);
|
|
const [formActionBusy, setFormActionBusy] = useState<string | null>(null);
|
|
const [formPageMsg, setFormPageMsg] = useState<string | null>(null);
|
|
const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null);
|
|
const [shareFormTarget, setShareFormTarget] = useState<FormWithSubmissionCount | null>(null);
|
|
const [copiedFormId, setCopiedFormId] = useState<string | null>(null);
|
|
|
|
const publicFormPageUrl = (f: FormWithSubmissionCount) => {
|
|
const base =
|
|
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) ||
|
|
window.location.origin.replace(/\/$/, "");
|
|
const eventId = formEventId(f);
|
|
const fid = f._id;
|
|
if (eventId) return `${base.replace(/\/$/, "")}/event/${eventId}/form/${fid}`;
|
|
return `${base.replace(/\/$/, "")}/form/${fid}`;
|
|
};
|
|
const FORM_LINK_COPIED_MSG = "Public form link copied to clipboard.";
|
|
|
|
const copyFormPublicLink = (f: FormWithSubmissionCount) => {
|
|
const url = publicFormPageUrl(f);
|
|
void navigator.clipboard.writeText(url).then(() => {
|
|
setCopiedFormId(f._id);
|
|
setFormPageMsg(FORM_LINK_COPIED_MSG);
|
|
window.setTimeout(() => {
|
|
setCopiedFormId(null);
|
|
setFormPageMsg((prev) => (prev === FORM_LINK_COPIED_MSG ? null : prev));
|
|
}, 4000);
|
|
}).catch(() => {
|
|
setFormPageMsg("Could not copy to clipboard. Check browser permissions or copy the link manually.");
|
|
});
|
|
};
|
|
|
|
const fetchForms = useCallback(async () => {
|
|
setLoadError(null);
|
|
if (!canReadForms) {
|
|
setForms([]);
|
|
return;
|
|
}
|
|
if (!orgId) {
|
|
setForms([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const list = await formsApi.list({ organization: orgId });
|
|
setForms(list);
|
|
} catch (err) {
|
|
setForms([]);
|
|
const msg = err instanceof Error && err.message ? err.message : "Could not load forms.";
|
|
setLoadError(msg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canReadForms, orgId]);
|
|
|
|
useEffect(() => {
|
|
fetchForms();
|
|
}, [fetchForms]);
|
|
|
|
const filteredForms = filterType === "Feedback"
|
|
? forms.filter(f => f.type === "feedback")
|
|
: filterType === "Registration"
|
|
? forms.filter(f => f.type === "registration")
|
|
: filterType === "Other"
|
|
? forms.filter(f => f.type === "other" || f.type === "survey" || f.type === "custom")
|
|
: forms;
|
|
|
|
const openCreate = () => {
|
|
setEditFormId(null);
|
|
if (filterType === "Registration") setShowReg(true);
|
|
else if (filterType === "Other") setShowOther(true);
|
|
else setShowFeedback(true);
|
|
};
|
|
|
|
const openEdit = (formId: string) => {
|
|
setEditFormId(formId);
|
|
const f = forms.find(x => x._id === formId);
|
|
if (f?.type === "registration") setShowReg(true);
|
|
else if (f?.type === "other" || f?.type === "survey" || f?.type === "custom") setShowOther(true);
|
|
else setShowFeedback(true);
|
|
};
|
|
|
|
const handlePanelSuccess = () => {
|
|
fetchForms();
|
|
setShowFeedback(false);
|
|
setShowReg(false);
|
|
setShowOther(false);
|
|
setEditFormId(null);
|
|
setFormPageMsg("Form saved.");
|
|
};
|
|
|
|
const runDeleteForm = async () => {
|
|
if (!formDeleteTarget) return;
|
|
const id = formDeleteTarget._id;
|
|
const label = formDeleteTarget.name;
|
|
setFormActionBusy(id);
|
|
try {
|
|
await formsApi.remove(id);
|
|
setFormDeleteTarget(null);
|
|
setFormPageMsg(`Deleted “${label}”.`);
|
|
await fetchForms();
|
|
} catch {
|
|
setFormPageMsg("Could not delete the form. Try again.");
|
|
} finally {
|
|
setFormActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const duplicateForm = async (f: FormWithSubmissionCount) => {
|
|
setFormActionBusy(f._id);
|
|
setFormPageMsg(null);
|
|
try {
|
|
const src = await formsApi.get(f._id);
|
|
const ev = src.event
|
|
? (typeof src.event === "string" ? src.event : (src.event as { _id?: string })._id)
|
|
: null;
|
|
if (!ev) {
|
|
setFormPageMsg("This form has no event. Edit it to attach an event, then duplicate.");
|
|
return;
|
|
}
|
|
const baseName = (src.name || "Form").trim();
|
|
const copyName = baseName.toLowerCase().endsWith(" (copy)") ? `${baseName} (2)` : `${baseName} (Copy)`;
|
|
await formsApi.create({
|
|
name: copyName,
|
|
type: src.type,
|
|
description: src.description ?? "",
|
|
fields: cloneFormFieldsForDuplicate(src.fields),
|
|
settings: cloneSettingsForDuplicate(src.settings),
|
|
event: String(ev),
|
|
});
|
|
setFormPageMsg(`Duplicated as “${copyName}”.`);
|
|
await fetchForms();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Could not duplicate the form. Try again.";
|
|
setFormPageMsg(msg);
|
|
} finally {
|
|
setFormActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const pageTitle = subPage === "forms-add" ? "Add New Form" : filterType ?? "All Forms";
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio · Forms & Workflow</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
|
|
{pageTitle}
|
|
</h1>
|
|
</div>
|
|
{canWriteForms ? (
|
|
<button onClick={openCreate} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={13} /> Create Form
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{formPageMsg ? (
|
|
<p role="status" aria-live="polite" style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)", margin: 0, padding: "10px 14px", borderRadius: "var(--radius-card)", background: formPageMsg === FORM_LINK_COPIED_MSG ? "#dcfce7" : "var(--secondary)", border: formPageMsg === FORM_LINK_COPIED_MSG ? "1px solid #86efac" : "1px solid var(--border)" }}>
|
|
{formPageMsg}
|
|
</p>
|
|
) : null}
|
|
|
|
{!canReadForms ? (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
|
<EmptyState icon={<FileText size={40} />} title="Access denied" description="You do not have permission to view forms." />
|
|
</div>
|
|
) : !orgId ? (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
|
<EmptyState icon={<FileText size={40} />} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
|
|
</div>
|
|
) : subPage === "forms-add" ? (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
|
<EmptyState icon={<FileText size={40} />} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} />
|
|
</div>
|
|
) : loadError ? (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
|
<EmptyState icon={<FileText size={40} />} title="Could not load forms" description={loadError} cta="Try again" onCta={() => void fetchForms()} />
|
|
</div>
|
|
) : (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", gap: "6px" }}>
|
|
{["Form Name", "Event", "Type", "Submissions", "Status", ""].map(h => (
|
|
<span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>
|
|
))}
|
|
</div>
|
|
{loading ? (
|
|
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading forms…</div>
|
|
) : filteredForms.length === 0 ? (
|
|
<div style={{ padding: "32px" }}>
|
|
<EmptyState icon={<FileText size={40} />} title="No forms found" description={`There are no ${pageTitle.toLowerCase()} to show for this organization.`} />
|
|
</div>
|
|
) : (
|
|
filteredForms.map(f => {
|
|
const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft";
|
|
const statusColor = status === "Active" ? { bg: "#dcfce7", color: "#15803d" } : status === "Draft" ? { bg: "var(--secondary)", color: "var(--muted)" } : { bg: "#f1f5f9", color: "#475569" };
|
|
const fid = f._id;
|
|
return (
|
|
<div key={fid}
|
|
onMouseEnter={() => setHovered(fid)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === fid ? "var(--secondary)" : "var(--card)", transition: "background 0.12s", gap: "6px" }}
|
|
>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={f.name}>{f.name}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={formEventName(f)}>{formEventName(f)}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formDisplayType(f.type)}</span>
|
|
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{(f.submissionCount ?? 0).toLocaleString()}</span>
|
|
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: statusColor.bg, color: statusColor.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{status}</span>
|
|
<div style={{ display: "flex", gap: "4px", flexWrap: "wrap", justifyContent: "flex-end" }}>
|
|
<button
|
|
type="button"
|
|
title={copiedFormId === fid ? "Copied to clipboard" : "Copy public form link"}
|
|
onClick={() => copyFormPublicLink(f)}
|
|
disabled={formActionBusy === fid}
|
|
style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: copiedFormId === fid ? "#dcfce7" : "var(--card)", color: copiedFormId === fid ? "#15803d" : "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
|
onMouseEnter={e => { if (copiedFormId !== fid) { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; } }}
|
|
onMouseLeave={e => { if (copiedFormId !== fid) { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; } }}
|
|
>
|
|
{copiedFormId === fid ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
|
|
</button>
|
|
{canReadForms ? (
|
|
<button type="button" title="Submissions" onClick={() => setSubmissionsPanelForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<List size={12} />
|
|
</button>
|
|
) : null}
|
|
{canWriteForms ? (
|
|
<button type="button" title="Edit" onClick={() => openEdit(fid)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<Pencil size={12} />
|
|
</button>
|
|
) : null}
|
|
{canWriteForms ? (
|
|
<button type="button" title="Share" onClick={() => setShareFormTarget(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<Share2 size={12} />
|
|
</button>
|
|
) : null}
|
|
{canWriteForms ? (
|
|
<button type="button" title="Duplicate" onClick={() => void duplicateForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<Copy size={12} />
|
|
</button>
|
|
) : null}
|
|
{canDeleteForms ? (
|
|
<button type="button" title="Delete" onClick={() => { setFormPageMsg(null); setFormDeleteTarget(f); }} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
{formDeleteTarget ? (
|
|
<ConfirmModal
|
|
title="Delete form"
|
|
message={`Delete “${formDeleteTarget.name}”? Submissions stored for this form will no longer be linked to a form definition.`}
|
|
confirmLabel="Delete"
|
|
variant="danger"
|
|
onConfirm={() => void runDeleteForm()}
|
|
onCancel={() => setFormDeleteTarget(null)}
|
|
/>
|
|
) : null}
|
|
|
|
<Footer />
|
|
{showFeedback && canWriteForms && (
|
|
<CreateFormPanel formId={editFormId ?? undefined} mode="feedback" onClose={() => { setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
|
)}
|
|
{showReg && canWriteForms && (
|
|
<CreateFormPanel formId={editFormId ?? undefined} mode="registration" onClose={() => { setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
|
)}
|
|
{showOther && canWriteForms && (
|
|
<CreateFormPanel formId={editFormId ?? undefined} mode="other" onClose={() => { setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
|
)}
|
|
{shareFormTarget ? (
|
|
<ShareFormPanel formId={shareFormTarget._id} onClose={() => setShareFormTarget(null)} />
|
|
) : null}
|
|
{submissionsPanelForm ? (
|
|
<FormSubmissionsPanel
|
|
formId={submissionsPanelForm._id}
|
|
formName={submissionsPanelForm.name}
|
|
onClose={() => setSubmissionsPanelForm(null)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Content
|
|
────────────────────────────────────── */
|
|
function formatLibraryBytes(n?: number): string {
|
|
if (n == null || Number.isNaN(n)) return "—";
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / 1048576).toFixed(1)} MB`;
|
|
}
|
|
|
|
function libraryTypeLabel(c: LibraryContent): string {
|
|
const t = c.type;
|
|
if (t === "image") return "Image";
|
|
if (t === "pdf") return "PDF";
|
|
if (t === "video") return "Video";
|
|
return "Other";
|
|
}
|
|
|
|
function formatShortDate(iso?: string): string {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
export function ContentPage({ subPage }: { subPage?: string }) {
|
|
const { user } = useAuth();
|
|
const orgId = portfolioListOrganizationId(user);
|
|
const canManageContent = Boolean(orgId) || user?.role === "super_admin";
|
|
const isCollections = subPage === "content-collections";
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [showUpload, setShowUpload] = useState(false);
|
|
const [showCollection, setShowCollection] = useState(false);
|
|
const [editingCollection, setEditingCollection] = useState<ContentCollectionRecord | null>(null);
|
|
const [library, setLibrary] = useState<LibraryContent[]>([]);
|
|
const [collections, setCollections] = useState<ContentCollectionRecord[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const refreshLibrary = useCallback(async () => {
|
|
if (!canManageContent) return;
|
|
if (!orgId) {
|
|
setLibrary([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const { media } = await listMedia({ limit: 400, organization: orgId });
|
|
setLibrary(media);
|
|
} catch {
|
|
setLibrary([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canManageContent, orgId]);
|
|
|
|
const refreshCollections = useCallback(async () => {
|
|
if (!canManageContent) return;
|
|
if (!orgId) {
|
|
setCollections([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const list = await contentCollectionsApi.list({ organization: orgId });
|
|
setCollections(list);
|
|
} catch {
|
|
setCollections([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canManageContent, orgId]);
|
|
|
|
useEffect(() => {
|
|
if (isCollections) void refreshCollections();
|
|
else void refreshLibrary();
|
|
}, [isCollections, refreshCollections, refreshLibrary]);
|
|
|
|
const handleContentSuccess = () => {
|
|
if (isCollections) void refreshCollections();
|
|
else void refreshLibrary();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio · Content</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{isCollections ? "Collections" : "Library"}</h1>
|
|
</div>
|
|
{canManageContent && (
|
|
<div style={{ display: "flex", gap: "8px" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isCollections) {
|
|
setEditingCollection(null);
|
|
setShowCollection(true);
|
|
} else setShowUpload(true);
|
|
}}
|
|
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = "0.88"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
|
|
>
|
|
<Plus size={13} /> {isCollections ? "New Collection" : "Add Files"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!canManageContent ? (
|
|
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Associate your account with an organization to manage content.</p>
|
|
) : (
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
|
{loading ? (
|
|
<div style={{ padding: "20px", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>Loading…</div>
|
|
) : isCollections ? (
|
|
<>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 100px 56px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
|
{["Collection", "Items", "Created", ""].map(h => <span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>)}
|
|
</div>
|
|
{collections.length === 0 ? (
|
|
<div style={{ padding: "24px", textAlign: "center", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>No collections yet.</div>
|
|
) : collections.map(col => (
|
|
<div key={col._id}
|
|
onMouseEnter={() => setHovered(col._id)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ display: "grid", gridTemplateColumns: "1fr 80px 100px 56px", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === col._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s", cursor: "default" }}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
|
<div style={{ width: "32px", height: "32px", borderRadius: "var(--radius-button)", background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
<BookOpen size={14} style={{ color: "var(--primary)" }} />
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{col.name}</span>
|
|
</div>
|
|
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{col.contentIds?.length ?? 0}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatShortDate(col.createdAt)}</span>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", opacity: hovered === col._id ? 1 : 0, transition: "opacity 0.15s" }}>
|
|
<button
|
|
type="button"
|
|
title="Edit collection"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingCollection(col);
|
|
setShowCollection(true);
|
|
}}
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: "32px", height: "32px", borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", color: "var(--muted)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
|
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
|
}}
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 80px 80px 50px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
|
{["File", "Type", "Size", "Added", ""].map(h => <span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>)}
|
|
</div>
|
|
{library.length === 0 ? (
|
|
<div style={{ padding: "24px", textAlign: "center", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>No files yet. Use Add Files to upload.</div>
|
|
) : library.map(f => (
|
|
<div key={f._id}
|
|
onMouseEnter={() => setHovered(f._id)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ display: "grid", gridTemplateColumns: "1fr 80px 80px 80px 50px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === f._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
|
>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)" }}>{f.name}</span>
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px", width: "fit-content" }}>{libraryTypeLabel(f)}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLibraryBytes(f.size)}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatShortDate(f.createdAt)}</span>
|
|
<div style={{ opacity: hovered === f._id ? 1 : 0, transition: "opacity 0.15s" }}>
|
|
{f.url && (
|
|
<a href={f.url} target="_blank" rel="noopener noreferrer" title="Open / download" style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex" }}
|
|
onClick={(e) => e.stopPropagation()}>
|
|
<Download size={13} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
<Footer />
|
|
</div>
|
|
|
|
{showUpload && canManageContent && (
|
|
<UploadFilePanel onClose={() => setShowUpload(false)} onSuccess={() => { handleContentSuccess(); setShowUpload(false); }} />
|
|
)}
|
|
{showCollection && canManageContent && (
|
|
<CreateCollectionPanel
|
|
key={editingCollection ? `edit-${editingCollection._id}` : "create"}
|
|
editing={editingCollection}
|
|
onClose={() => { setShowCollection(false); setEditingCollection(null); }}
|
|
onSuccess={() => { handleContentSuccess(); setShowCollection(false); setEditingCollection(null); }}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Messaging
|
|
────────────────────────────────────── */
|
|
|
|
export function MessagingPage({ subPage }: { subPage?: string }) {
|
|
if (subPage === "messaging-email") return <MessagingEmailPage />;
|
|
if (subPage === "messaging-whatsapp") return <MessagingWhatsappPage />;
|
|
|
|
return <MessagingEmailPage />;
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
User Management (Global)
|
|
────────────────────────────────────── */
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
super_admin: "Super Admin",
|
|
admin: "Admin",
|
|
moderator: "Moderator",
|
|
viewer: "Viewer",
|
|
};
|
|
const ROLE_STYLES: Record<string, { bg: string; color: string }> = {
|
|
super_admin: { bg: "#fef3c7", color: "#92400e" },
|
|
admin: { bg: "var(--accent)", color: "var(--accent-foreground)" },
|
|
moderator: { bg: "#dcfce7", color: "#15803d" },
|
|
viewer: { bg: "var(--secondary)", color: "var(--muted)" },
|
|
};
|
|
function userDisplayName(u: User): string {
|
|
if (u.firstName || u.lastName) return [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
|
return u.email?.split("@")[0] ?? "User";
|
|
}
|
|
function userInitials(u: User): string {
|
|
const name = userDisplayName(u);
|
|
return name.slice(0, 2).toUpperCase() || "?";
|
|
}
|
|
|
|
export function UserManagementPage() {
|
|
const { setActiveNav } = useAppNav();
|
|
const { canModule } = usePermission();
|
|
const canReadUsers = canModule("user_management", "read");
|
|
const canWriteUsers = canModule("user_management", "write");
|
|
const canDeleteUsers = canModule("user_management", "delete");
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [showInvite, setShowInvite] = useState(false);
|
|
const [editUser, setEditUser] = useState<User | null>(null);
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await usersApi.list();
|
|
setUsers(res.data?.users ?? []);
|
|
} catch {
|
|
setUsers([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, [fetchUsers]);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
|
{!canReadUsers ? (
|
|
<div style={{ padding: 32, textAlign: "center", color: "var(--muted)" }}>You do not have permission to view user management.</div>
|
|
) : (
|
|
<>
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
|
<div>
|
|
<p style={labelStyle}>Portfolio</p>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>User Management</h1>
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{users.length} users across the platform</p>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
{canModule("user_management", "read") ? (
|
|
<button type="button" onClick={() => setActiveNav("roles-management")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--secondary)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: "pointer", fontSize: "var(--text-sm)" }}>
|
|
<Shield size={13} /> Manage Roles
|
|
</button>
|
|
) : null}
|
|
{canWriteUsers ? (
|
|
<button onClick={() => { setEditUser(null); setShowInvite(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
<Plus size={13} /> Invite User
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr 120px 80px 60px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
|
|
{["User", "Email", "Role", "Events", ""].map(h => <span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>)}
|
|
</div>
|
|
{loading ? (
|
|
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading users…</div>
|
|
) : (
|
|
users.map((u, idx) => {
|
|
const rs = ROLE_STYLES[u.membershipRole ?? u.role] ?? ROLE_STYLES[u.role] ?? { bg: "var(--secondary)", color: "var(--muted)" };
|
|
const roleLabel = u.customRole?.name ?? ROLE_LABELS[u.membershipRole ?? u.role] ?? u.role;
|
|
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
|
|
const assigned = u.assignedEvents?.length ?? 0;
|
|
const eventsLabel = assigned > 0 ? String(assigned) : (u.role === "super_admin" || u.role === "admin" ? "All" : "—");
|
|
return (
|
|
<div key={u._id}
|
|
onMouseEnter={() => setHovered(u._id)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr 120px 80px 60px", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === u._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
|
<div style={{ width: "30px", height: "30px", borderRadius: "50%", background: color, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{userInitials(u)}</span>
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{userDisplayName(u)}</span>
|
|
</div>
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{u.email}</span>
|
|
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: rs.bg, color: rs.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{roleLabel}</span>
|
|
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{eventsLabel}</span>
|
|
<div style={{ display: "flex", gap: "4px", opacity: hovered === u._id ? 1 : 0, transition: "opacity 0.15s" }}>
|
|
{canWriteUsers ? (
|
|
<button type="button" onClick={() => { setEditUser(u); setShowInvite(true); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
|
<Pencil size={11} />
|
|
</button>
|
|
) : null}
|
|
{canDeleteUsers ? (
|
|
<button type="button" onClick={() => { if (window.confirm(`Remove ${userDisplayName(u)}?`)) void usersApi.remove(u._id).then(() => fetchUsers()); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<Footer />
|
|
{showInvite && canWriteUsers && <InviteUserPanel editUser={editUser} onClose={() => { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ──────────────────────────────────────
|
|
Footer
|
|
────────────────────────────────────── */
|
|
function Footer() {
|
|
return (
|
|
<div style={{ paddingTop: "8px", borderTop: "1px solid var(--border)", textAlign: "center" }}>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>© 2026 Stratawiz Technologies Pvt Ltd · EventBuzz</p>
|
|
</div>
|
|
);
|
|
} |