640 lines
24 KiB
TypeScript
640 lines
24 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 {
|
|
// Extend payload with submissionId; backend uses it to persist replied status.
|
|
await (formsApi as any).replyToSubmission(formId, {
|
|
submissionId: replyTarget._id,
|
|
to,
|
|
subject: replySubject,
|
|
body: replyBody,
|
|
});
|
|
setReplyMessage("Email sent successfully.");
|
|
setReplyTarget(null);
|
|
setReplySubject("");
|
|
setReplyBody("");
|
|
await fetchPage();
|
|
} 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);
|
|
const hasEmail = participantEmail(s.participant) !== "—";
|
|
|
|
const isReplied = !!s.replied;
|
|
return (
|
|
<tr
|
|
key={s._id}
|
|
style={{
|
|
borderBottom: "1px solid var(--border)",
|
|
verticalAlign: "top",
|
|
background: isReplied ? "rgba(16, 185, 129, 0.08)" : undefined,
|
|
}}
|
|
>
|
|
<td style={{ padding: "10px 12px" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => hasEmail && !isReplied && openReply(s)}
|
|
disabled={!hasEmail || isReplied}
|
|
title={isReplied ? "Already replied" : !hasEmail ? "No email available" : "Reply"}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: isReplied ? "rgba(16, 185, 129, 0.15)" : hasEmail ? "var(--secondary)" : "var(--background)",
|
|
color: isReplied ? "var(--foreground)" : hasEmail ? "var(--foreground)" : "var(--muted)",
|
|
cursor: !hasEmail || isReplied ? "not-allowed" : "pointer",
|
|
opacity: !hasEmail ? 0.5 : 1,
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<MailPlus size={14} />
|
|
{isReplied ? "Replied" : "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;
|
|
}
|