Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown, early-bird discount, balance due, Yoco pay-now link/QR) whenever a balance is outstanding; payment/donation confirmations attach a payment receipt PDF. Sent as an email attachment and, over WhatsApp, as the PDF itself with the existing message as its caption. - Users can also (re)send either document on demand: an "Invoice" button on the registration detail popup, and a "Receipt" button next to each payment there and on the Payment history page, each opening an Email/WhatsApp choice popup, via two new endpoints restricted to the registration/payment's own owner. - Fix: editing an event option's early-bird tiers deleted and recreated every tier for that option with brand-new ids, silently severing the appliedTierId link on all historical purchases (losing early-bird attribution and undercounting stock-limit usage) even for tiers the admin didn't touch. Tiers are now upserted by id. - Update the "My Events" help content and the API docs index for the new endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
|
||||
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
try {
|
||||
const tiers = rows
|
||||
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
|
||||
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
|
||||
.map((r, i) => ({ id: r.id, deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
|
||||
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
|
||||
onSave(tiers);
|
||||
} finally {
|
||||
|
||||
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
|
||||
|
||||
// Registration details modal
|
||||
const [activeRegId, setActiveRegId] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
|
||||
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
|
||||
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
|
||||
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
|
||||
|
||||
// Track whether the active registration's event has attendee forms
|
||||
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
|
||||
@@ -253,26 +255,6 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const emailRegistration = async (registrationId: string) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
// Show loading dialog while sending
|
||||
setDialog({ open: true, message: "Sending tickets…", loading: true });
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
method: "POST",
|
||||
body: { registrationId },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
} catch (e: any) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to email registration tickets");
|
||||
}
|
||||
};
|
||||
|
||||
const whatsappTickets = async (ticketIds: string[]) => {
|
||||
if (!token || ticketIds.length === 0) return;
|
||||
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
|
||||
@@ -293,26 +275,60 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const whatsappRegistration = async (registrationId: string) => {
|
||||
const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
|
||||
if (!token) return;
|
||||
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
|
||||
setDialog({
|
||||
open: true, loading: true, message: "",
|
||||
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
|
||||
loadingSubtitle: "Please wait while we prepare your invoice.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
body: { registrationId, channel: "whatsapp" },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e: any) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send tickets to WhatsApp");
|
||||
setError(e?.message || "Failed to send invoice");
|
||||
}
|
||||
};
|
||||
|
||||
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setDialog({
|
||||
open: true, loading: true, message: "",
|
||||
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
|
||||
loadingSubtitle: "Please wait while we prepare your receipt.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
|
||||
} catch (e: any) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
}
|
||||
};
|
||||
|
||||
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
|
||||
// whichever id it was opened with, then closes the popup.
|
||||
const chooseChannel = (channel: 'email' | 'whatsapp') => {
|
||||
if (!channelPicker) return;
|
||||
const { kind, id } = channelPicker;
|
||||
setChannelPicker(null);
|
||||
if (kind === 'invoice') sendInvoice(id, channel);
|
||||
else sendReceipt(id, channel);
|
||||
};
|
||||
|
||||
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
|
||||
// choosing a partial amount is only available from the supervisor payments dashboard.
|
||||
const payNow = async (registrationId: string) => {
|
||||
@@ -1129,9 +1145,12 @@ export default function UserDashboardPage() {
|
||||
{activeBill && activeBill.payments.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium mb-1">Payments</div>
|
||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||
<ul className="text-sm space-y-1">
|
||||
{activeBill.payments.map((p: any) => (
|
||||
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
|
||||
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
|
||||
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1143,24 +1162,21 @@ export default function UserDashboardPage() {
|
||||
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
||||
>Attendee forms</button>
|
||||
)}
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
|
||||
>Invoice</button>
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => payNow(activeReg.id)}
|
||||
>Make payment</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
||||
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
||||
printTickets(list);
|
||||
}}>Print all tickets</button>
|
||||
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
|
||||
{user?.phoneNumber && (
|
||||
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
||||
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
||||
printTickets(list);
|
||||
}}>Print all tickets</button>
|
||||
</div>
|
||||
|
||||
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
|
||||
@@ -1194,6 +1210,31 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelPicker && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
|
||||
onClick={() => setChannelPicker(null)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
|
||||
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => chooseChannel('email')}
|
||||
>Email</button>
|
||||
{user?.phoneNumber && (
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => chooseChannel('whatsapp')}
|
||||
>WhatsApp</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog.open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
@@ -1208,7 +1249,7 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-lg font-semibold mb-2">Tickets sent</div>
|
||||
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
|
||||
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
|
||||
const [payments, setPayments] = useState<PaymentItem[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
|
||||
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
|
||||
const [sendingReceipt, setSendingReceipt] = useState(false);
|
||||
|
||||
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
|
||||
if (!token) return;
|
||||
setReceiptPickerId(null);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setSendingReceipt(true);
|
||||
try {
|
||||
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setInfo((res && res.message) || "Receipt sent.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
} finally {
|
||||
setSendingReceipt(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filters
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt…</p>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
|
||||
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
|
||||
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
|
||||
</div>
|
||||
{!isRefund && (
|
||||
<button
|
||||
className="text-xs text-brand-700 hover:underline shrink-0"
|
||||
disabled={sendingReceipt}
|
||||
onClick={() => setReceiptPickerId(p.id)}
|
||||
>Receipt</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{receiptPickerId && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={() => setReceiptPickerId(null)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-base font-semibold mb-1">Send receipt</div>
|
||||
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => sendReceipt(receiptPickerId, 'email')}
|
||||
>Email</button>
|
||||
{user?.phoneNumber && (
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => sendReceipt(receiptPickerId, 'whatsapp')}
|
||||
>WhatsApp</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt } from "lucide-react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
|
||||
Tap a registration to change quantities or options, pay what's outstanding, or cancel it entirely.
|
||||
</GuideItem>
|
||||
<GuideItem icon={FileText} title="Invoice" tone="rose">
|
||||
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird discount, and what's still owing (or "Paid in full") — to your email or WhatsApp, whichever you pick.
|
||||
</GuideItem>
|
||||
<GuideItem icon={History} title="Show past events" tone="gray">
|
||||
Registrations page defaults to upcoming events only — tick "Show past events" at the top to bring back ones that have already happened.
|
||||
</GuideItem>
|
||||
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={Receipt} title="Payment history" tone="green">
|
||||
The Payment history page lists every payment you've made across all your registrations, past and present.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Receipt} title="Receipts" tone="amber">
|
||||
Every payment — in a registration's Payments list, or on the Payment history page — has its own Receipt button. Pick email or WhatsApp to get a PDF receipt for that specific payment.
|
||||
</GuideItem>
|
||||
<GuideItem icon={HandHeart} title="Donations" tone="rose">
|
||||
A donation isn't tied to any one registration — make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance.
|
||||
</GuideItem>
|
||||
|
||||
Reference in New Issue
Block a user