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

624 lines
23 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import { X, Download, ChevronLeft, ChevronRight, MailPlus } from "lucide-react";
import { formsApi } from "../../../services/api";
import { useAuth } from "../../contexts/AuthContext";
import type { Form, FormField, FormSubmissionRecord } from "../../../services/api";
import { buildFormSubmissionsExportFilename } from "../../utils/formExportFilename";
function dataToObj(data: unknown): Record<string, unknown> {
if (!data || typeof data !== "object") return {};
return data as Record<string, unknown>;
}
function formatWhen(iso?: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return "—";
}
}
function participantEmail(
p: FormSubmissionRecord["participant"]
): string {
if (!p || typeof p === "string") return "—";
return p.email?.trim() || "—";
}
function participantName(p: FormSubmissionRecord["participant"]): string {
if (!p || typeof p === "string") return "there";
return [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "there";}
function eventNameFromForm(form: Form | null): string {
const ev = form?.event;
if (ev && typeof ev === "object" && ev.name?.trim()) return ev.name.trim();
return "Event";
}
type Props = {
formId: string;
formName: string;
onClose: () => void;
};
const PAGE_SIZE = 20;
/** Response field columns in the table; CSV export always includes every field (see backend). */
const SUBMISSIONS_TABLE_PREVIEW_FIELDS = 4;
export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
const [formDef, setFormDef] = useState<Form | null>(null);
const [q, setQ] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [appliedFrom, setAppliedFrom] = useState("");
const [appliedTo, setAppliedTo] = useState("");
const [page, setPage] = useState(1);
const [rows, setRows] = useState<FormSubmissionRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [replyTarget, setReplyTarget] = useState<FormSubmissionRecord | null>(null);
const [replySubject, setReplySubject] = useState("");
const [replyBody, setReplyBody] = useState("");
const [replying, setReplying] = useState(false);
const [replyMessage, setReplyMessage] = useState<string | null>(null);
const { user } = useAuth();
const fieldLabels = (formDef?.fields || [])
.map((f: FormField) => f.label?.trim())
.filter(Boolean) as string[];
const tableFieldLabels = fieldLabels.slice(0, SUBMISSIONS_TABLE_PREVIEW_FIELDS);
const tableHiddenFieldCount = Math.max(0, fieldLabels.length - tableFieldLabels.length);
const loadForm = useCallback(async () => {
try {
const f = await formsApi.get(formId);
setFormDef(f);
} catch {
setFormDef(null);
}
}, [formId]);
const fetchPage = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const res = await formsApi.listSubmissions(formId, {
q: appliedQ || undefined,
dateFrom: appliedFrom || undefined,
dateTo: appliedTo || undefined,
page,
limit: PAGE_SIZE,
});
setRows(res.submissions);
setTotal(res.total);
} catch (e) {
setErr(e instanceof Error ? e.message : "Failed to load submissions.");
setRows([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [formId, appliedQ, appliedFrom, appliedTo, page]);
useEffect(() => {
void loadForm();
}, [loadForm]);
useEffect(() => {
void fetchPage();
}, [fetchPage]);
const applyFilters = () => {
setAppliedQ(q.trim());
setAppliedFrom(dateFrom);
setAppliedTo(dateTo);
setPage(1);
};
const onExport = async () => {
setExporting(true);
try {
const { blob } = await formsApi.exportSubmissionsCsv(formId, {
q: appliedQ || undefined,
dateFrom: appliedFrom || undefined,
dateTo: appliedTo || undefined,
});
const filename = buildFormSubmissionsExportFilename(
eventNameFromForm(formDef),
formName || formDef?.name || "Form",
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setErr(e instanceof Error ? e.message : "Export failed.");
} finally {
setExporting(false);
}
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const openReply = (submission: FormSubmissionRecord) => {
const email = participantEmail(submission.participant);
if (email === "—") {
setReplyMessage("This submission does not include an email address.");
return;
}
setReplyTarget(submission);
setReplySubject(`Re: Your response to ${formName}`);
setReplyBody(`Hi ${participantName(submission.participant)},\n\nThank you for your response.\n\nBest regards,\n${user?.firstName || user?.email || "EventBuzz"}`);
setReplyMessage(null);
};
const sendReply = async () => {
if (!replyTarget) return;
const to = participantEmail(replyTarget.participant);
if (!to || to === "—") {
setReplyMessage("Please choose a submission with a valid email address.");
return;
}
setReplying(true);
setReplyMessage(null);
try {
await formsApi.replyToSubmission(formId, { to, subject: replySubject, body: replyBody });
setReplyMessage("Email sent successfully.");
setReplyTarget(null);
setReplySubject("");
setReplyBody("");
} catch (e) {
setReplyMessage(e instanceof Error ? e.message : "Failed to send reply.");
} finally {
setReplying(false);
}
};
return (
<>
<div
onClick={onClose}
role="presentation"
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 200 }}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="form-submissions-title"
style={{
position: "fixed",
top: 0,
right: 0,
height: "100%",
width: "min(720px, 100vw)",
background: "var(--card)",
zIndex: 201,
display: "flex",
flexDirection: "column",
boxShadow: "-4px 0 40px rgba(0,0,0,0.2)",
animation: "formSubmissionsSlideIn 0.2s ease",
}}
>
<style>{`@keyframes formSubmissionsSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
padding: "16px 18px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ minWidth: 0 }}>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
margin: "0 0 4px",
}}
>
Submissions
</p>
<h2
id="form-submissions-title"
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-lg)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
lineHeight: 1.3,
wordBreak: "break-word",
}}
>
{formName}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
flexShrink: 0,
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--muted)",
cursor: "pointer",
}}
>
<X size={18} />
</button>
</div>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
gap: 10,
flexShrink: 0,
}}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "flex-end" }}>
<div style={{ flex: "1 1 160px", minWidth: 0 }}>
<label
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
color: "var(--muted)",
textTransform: "uppercase",
display: "block",
marginBottom: 4,
}}
>
Search (* wildcards)
</label>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && applyFilters()}
placeholder="e.g. john*@*"
style={{
width: "100%",
padding: "8px 10px",
fontSize: "var(--text-sm)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
boxSizing: "border-box",
}}
/>
</div>
<div>
<label
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
color: "var(--muted)",
textTransform: "uppercase",
display: "block",
marginBottom: 4,
}}
>
From
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
style={{
padding: "8px 10px",
fontSize: "var(--text-sm)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
}}
/>
</div>
<div>
<label
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
color: "var(--muted)",
textTransform: "uppercase",
display: "block",
marginBottom: 4,
}}
>
To
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
style={{
padding: "8px 10px",
fontSize: "var(--text-sm)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
}}
/>
</div>
<button
type="button"
onClick={applyFilters}
style={{
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
color: "var(--foreground)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
cursor: "pointer",
}}
>
Apply
</button>
<button
type="button"
onClick={() => void onExport()}
disabled={exporting || total === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
cursor: exporting || total === 0 ? "not-allowed" : "pointer",
opacity: exporting || total === 0 ? 0.6 : 1,
}}
>
<Download size={14} />
{exporting ? "Exporting…" : "Export CSV"}
</button>
</div>
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>
Showing {total.toLocaleString()} match{total === 1 ? "" : "es"}. List view is paged; export includes all matches and{" "}
<strong style={{ color: "var(--foreground)" }}>all form fields</strong> as columns.
{tableHiddenFieldCount > 0
? ` The table only shows the first ${SUBMISSIONS_TABLE_PREVIEW_FIELDS} response fields (${tableHiddenFieldCount} more in the file only).`
: null}
</p>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 0 16px" }}>
{err ? (
<p style={{ padding: "16px 18px", margin: 0, color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{err}</p>
) : null}
{loading ? (
<p style={{ padding: "24px 18px", margin: 0, color: "var(--muted)" }}>Loading…</p>
) : rows.length === 0 ? (
<p style={{ padding: "24px 18px", margin: 0, color: "var(--muted)" }}>No submissions for this filter.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
}}
>
<thead>
<tr style={{ background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Reply</th>
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Submitted</th>
<th style={{ textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap" }}>Email</th>
{tableFieldLabels.map((lab) => (
<th key={lab} style={{ textAlign: "left", padding: "10px 12px", maxWidth: 180 }}>
{lab}
</th>
))}
{tableHiddenFieldCount > 0 ? (
<th
key="__more__"
style={{ textAlign: "left", padding: "10px 12px", color: "var(--muted)", fontWeight: 600, whiteSpace: "nowrap" }}
>
+{tableHiddenFieldCount} more…
</th>
) : null}
</tr>
</thead>
<tbody>
{rows.map((s) => {
const o = dataToObj(s.data);
return (
<tr key={s._id} style={{ borderBottom: "1px solid var(--border)", verticalAlign: "top" }}>
<td style={{ padding: "10px 12px" }}>
<button
type="button"
onClick={() => openReply(s)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
color: "var(--foreground)",
cursor: "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
}}
>
<MailPlus size={14} />
Reply
</button>
</td>
<td style={{ padding: "10px 12px", whiteSpace: "nowrap", color: "var(--foreground)" }}>
{formatWhen(s.submittedAt)}
</td>
<td style={{ padding: "10px 12px", wordBreak: "break-all" }}>{participantEmail(s.participant)}</td>
{tableFieldLabels.map((lab) => (
<td key={lab} style={{ padding: "10px 12px", wordBreak: "break-word", maxWidth: 220 }}>
{formatCell(o[lab])}
</td>
))}
{tableHiddenFieldCount > 0 ? (
<td
key="__more__"
style={{ padding: "10px 12px", color: "var(--muted)", fontSize: "10px", fontStyle: "italic" }}
>
Export for full data
</td>
) : null}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{replyTarget ? (
<div
style={{
padding: "16px 18px",
borderTop: "1px solid var(--border)",
background: "var(--background)",
display: "flex",
flexDirection: "column",
gap: 10,
flexShrink: 0,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
<div>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600, color: "var(--foreground)" }}>
Reply to {participantEmail(replyTarget.participant)}
</p>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
This sends a real email from the connected mailbox configuration.
</p>
</div>
<button type="button" onClick={() => { setReplyTarget(null); setReplySubject(""); setReplyBody(""); setReplyMessage(null); }} style={{ border: "none", background: "transparent", color: "var(--muted)", cursor: "pointer" }}>
Cancel
</button>
</div>
<input
value={replySubject}
onChange={(e) => setReplySubject(e.target.value)}
placeholder="Subject"
style={{ padding: "8px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--foreground)" }}
/>
<textarea
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
placeholder="Write your message here"
rows={8}
style={{ padding: "8px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--foreground)", resize: "vertical" }}
/>
{replyMessage ? <p style={{ margin: 0, fontSize: "var(--text-xs)", color: replyMessage.includes("success") ? "var(--success)" : "var(--destructive)" }}>{replyMessage}</p> : null}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button type="button" onClick={() => void sendReply()} disabled={replying} style={{ padding: "8px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: replying ? "not-allowed" : "pointer", opacity: replying ? 0.7 : 1 }}>
{replying ? "Sending…" : "Send reply"}
</button>
</div>
</div>
) : null}
<div
style={{
padding: "12px 18px",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexShrink: 0,
}}
>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>
Page {page} of {totalPages}
</span>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: page <= 1 || loading ? "not-allowed" : "pointer",
opacity: page <= 1 || loading ? 0.5 : 1,
}}
>
<ChevronLeft size={16} /> Prev
</button>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => p + 1)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: page >= totalPages || loading ? "not-allowed" : "pointer",
opacity: page >= totalPages || loading ? 0.5 : 1,
}}
>
Next <ChevronRight size={16} />
</button>
</div>
</div>
</div>
</>
);
}
function formatCell(v: unknown): string {
if (v == null) return "—";
if (typeof v === "boolean") return v ? "Yes" : "No";
if (Array.isArray(v)) return v.join(", ");
const s = String(v);
if (s.length > 500) return `${s.slice(0, 500)}…`;
return s;
}