+ {/* Outstanding Balance Banner */}
+ {totalPending > 0 && (
+
+
+
Outstanding Balance
+
{formatCents(totalPending)}
+
+ {pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
+
- )}
-
+
+
+ )}
- {/* Packages */}
-
- Packages
- {packages.length === 0 ? (
- No packages purchased
- ) : (
-
- {packages.map((pkg, index) => (
-
- {pkg.name}
- {pkg.remaining} remaining
-
- ))}
-
- )}
-
+ {/* Tabs */}
+
+ {([
+ { id: "invoices" as const, label: "Invoices", icon: DollarSign },
+ { id: "payment" as const, label: "Payment Methods", icon: CreditCard },
+ { id: "packages" as const, label: "Packages", icon: Package },
+ ]).map(({ id, label, icon: Icon }) => (
+
+ ))}
+
{/* Invoices */}
-
- Invoice History
- {invoices.length === 0 ? (
- No invoices yet
- ) : (
-
- {invoices.map((invoice) => (
-
-
-
- {invoice.description || `Invoice ${invoice.id.slice(0, 8)}`}
-
-
{invoice.date}
+ {tab === "invoices" && (
+
+
+
+
+
+ | Date |
+ Description |
+ Amount |
+ Status |
+ |
+
+
+
+ {invoices.map((inv) => (
+
+ |
+ {new Date(inv.date).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+ |
+
+ {inv.description || `Invoice ${inv.id.slice(0, 8)}`}
+ |
+
+ {formatCents(inv.totalCents)}
+ |
+
+
+ {inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
+
+ |
+
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Payment Methods */}
+ {tab === "payment" && (
+
+ {paymentMethods.length === 0 ? (
+
No payment methods on file
+ ) : (
+
+ {paymentMethods.map((method) => (
+
+
+
+ {method.brand.toUpperCase()}
+
+
**** {method.last4}
+
+ {method.expiryMonth}/{method.expiryYear}
+
+
+ {!readOnly && (
+
+ )}
-
-
- {formatCents(invoice.totalCents)}
-
-
- {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
-
+ ))}
+
+ )}
+
+ {/* Autopay */}
+
+
+
+
+
+
+
+
Autopay
+
+ Automatically charge after each appointment
+
- ))}
+ {!readOnly ? (
+
+ ) : (
+
+ {autopay ? "Enabled" : "Disabled"}
+
+ )}
+
- )}
-
+
+ )}
+
+ {/* Packages */}
+ {tab === "packages" && (
+
+ {packages.length === 0 ? (
+
No packages purchased
+ ) : (
+ packages.map((pkg, index) => (
+
+
+ {pkg.name}
+ {pkg.remaining} remaining
+
+
+ ))
+ )}
+
+ )}
+
+ {/* Payment Modal */}
+ {showPaymentModal && (
+
setShowPaymentModal(false)}
+ />
+ )}
+
+ );
+}
+
+function PaymentModal({
+ pending,
+ totalPending: _totalPending,
+ onClose,
+}: {
+ pending: Invoice[];
+ totalPending: number;
+ onClose: () => void;
+}) {
+ const [selectedInvoices, setSelectedInvoices] = useState
>(
+ new Set(pending.map((i) => i.id))
+ );
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [isComplete, setIsComplete] = useState(false);
+
+ const formatCents = (cents: number) =>
+ new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(cents / 100);
+
+ const toggleInvoice = (id: string) => {
+ const next = new Set(selectedInvoices);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ setSelectedInvoices(next);
+ };
+
+ const handlePay = async () => {
+ setIsProcessing(true);
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ setIsProcessing(false);
+ setIsComplete(true);
+ };
+
+ const selectedTotal = pending
+ .filter((i) => selectedInvoices.has(i.id))
+ .reduce((sum, i) => sum + i.totalCents, 0);
+
+ if (isComplete) {
+ return (
+
+
+
+
Payment Successful
+
+ Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Pay Outstanding Balance
+
+
+
+
Select invoices to pay:
+
+
+ {pending.map((inv) => (
+
+ ))}
+
+
+
+
+ Total
+
+ {formatCents(selectedTotal)}
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx
index a8d471b..9e74e77 100644
--- a/apps/web/src/portal/sections/ReportCards.tsx
+++ b/apps/web/src/portal/sections/ReportCards.tsx
@@ -24,36 +24,49 @@ interface Appointment {
reportCardId?: string;
}
-export function ReportCards() {
+interface ReportCardsProps {
+ sessionId: string | null;
+}
+
+export function ReportCards({ sessionId }: ReportCardsProps) {
const [appointments, setAppointments] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedCard, setSelectedCard] = useState(null);
- useEffect(() => {
- const fetchReportCards = async () => {
- try {
- const response = await fetch("/api/portal/appointments");
+ const loadReportCards = async () => {
+ if (!sessionId) {
+ setAppointments([]);
+ setIsLoading(false);
+ return;
+ }
+ try {
+ setError(null);
+ setIsLoading(true);
+ const response = await fetch("/api/portal/appointments", {
+ headers: { "X-Impersonation-Session-Id": sessionId },
+ });
- if (response.ok) {
- const data = await response.json();
- const allAppointments: Appointment[] = data.appointments || data || [];
- const reportCardAppointments = allAppointments.filter(
- (appt) => appt.reportCardId
- );
- setAppointments(reportCardAppointments);
- } else {
- setError("Failed to load report cards.");
- }
- } catch {
- setError("Failed to load report cards. Please try again.");
- } finally {
- setIsLoading(false);
+ if (response.ok) {
+ const data = await response.json();
+ const allAppointments: Appointment[] = data.appointments || data || [];
+ const reportCardAppointments = allAppointments.filter(
+ (appt) => appt.reportCardId
+ );
+ setAppointments(reportCardAppointments);
+ } else {
+ setError("Failed to load report cards.");
}
- };
+ } catch {
+ setError("Failed to load report cards. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
- fetchReportCards();
- }, []);
+ useEffect(() => {
+ loadReportCards();
+ }, [sessionId]);
if (isLoading) {
return (
@@ -69,7 +82,7 @@ export function ReportCards() {
{error}