Files
acmcc/src/components/dashboard/AutopayManagementCard.tsx
T
2026-06-01 20:19:26 -04:00

275 lines
9.9 KiB
TypeScript

import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { CreditCard, Zap, Loader2, Play, XCircle, Users } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
interface Enrollment {
id: string;
association_id: string;
owner_id: string | null;
unit_id: string | null;
payment_method_type: string;
is_active: boolean;
created_at: string;
owners?: { first_name: string; last_name: string } | null;
units?: { unit_number: string } | null;
associations?: { name: string } | null;
}
export function AutopayManagementCard() {
const [enrollments, setEnrollments] = useState<Enrollment[]>([]);
const [loading, setLoading] = useState(true);
const [processDialogOpen, setProcessDialogOpen] = useState(false);
const [processing, setProcessing] = useState(false);
const [selectedAssociation, setSelectedAssociation] = useState("");
const [amountDollars, setAmountDollars] = useState("");
const [description, setDescription] = useState("Monthly Assessment");
const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
loadEnrollments();
}, []);
const loadEnrollments = async () => {
setLoading(true);
try {
const { data } = await supabase
.from("autopay_enrollments")
.select("*, owners(first_name, last_name), units(unit_number), associations(name)")
.eq("is_active", true)
.order("created_at", { ascending: false });
setEnrollments((data as Enrollment[]) || []);
// Get unique associations
const assocMap = new Map<string, string>();
(data || []).forEach((e: any) => {
if (e.association_id && e.associations?.name) {
assocMap.set(e.association_id, e.associations.name);
}
});
setAssociations(Array.from(assocMap.entries()).map(([id, name]) => ({ id, name })));
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const cancelEnrollment = async (id: string) => {
const { error } = await supabase
.from("autopay_enrollments")
.update({ is_active: false })
.eq("id", id);
if (error) {
toast.error("Failed to cancel");
} else {
toast.success("Autopay cancelled");
loadEnrollments();
}
};
const processAutopay = async () => {
if (!selectedAssociation || !amountDollars) {
toast.error("Select association and enter amount");
return;
}
const amountCents = Math.round(parseFloat(amountDollars) * 100);
if (isNaN(amountCents) || amountCents <= 0) {
toast.error("Invalid amount");
return;
}
setProcessing(true);
try {
const session = (await supabase.auth.getSession()).data.session;
const projectId = import.meta.env.VITE_SUPABASE_PROJECT_ID;
const res = await fetch(
`https://${projectId}.supabase.co/functions/v1/process-autopay`,
{
method: "POST",
headers: {
Authorization: `Bearer ${session?.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
association_id: selectedAssociation,
amount_cents: amountCents,
description,
}),
}
);
const data = await res.json();
if (data.error && !data.results) {
toast.error(data.error);
} else {
const s = data.summary;
toast.success(
`Processed ${s.total} payment(s): ${s.succeeded} succeeded, ${s.failed} failed`
);
setProcessDialogOpen(false);
}
} catch (err: any) {
toast.error(err.message || "Failed to process");
} finally {
setProcessing(false);
}
};
const filteredEnrollments = selectedAssociation
? enrollments.filter((e) => e.association_id === selectedAssociation)
: enrollments;
return (
<>
<Card className="border shadow-sm">
<CardHeader className="pb-2 px-4 pt-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" /> Autopay Enrollments
</CardTitle>
<CardDescription className="text-[12px]">
{enrollments.length} active enrollment{enrollments.length !== 1 ? "s" : ""}
</CardDescription>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setProcessDialogOpen(true)}
disabled={enrollments.length === 0}
>
<Play className="h-3 w-3 mr-1" /> Process Payments
</Button>
</div>
</CardHeader>
<CardContent className="px-0 pb-0">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : enrollments.length === 0 ? (
<div className="text-center py-8 px-4">
<Users className="h-6 w-6 text-muted-foreground/20 mx-auto mb-2" />
<p className="text-[13px] text-muted-foreground">No autopay enrollments</p>
</div>
) : (
<div className="divide-y divide-border max-h-[300px] overflow-y-auto">
{enrollments.slice(0, 10).map((e) => (
<div
key={e.id}
className="flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-foreground truncate">
{e.owners
? `${e.owners.first_name} ${e.owners.last_name}`
: "Unknown Owner"}
{e.units ? ` — Unit ${e.units.unit_number}` : ""}
</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-2xs">
<CreditCard className="h-2.5 w-2.5 mr-0.5" />
{e.payment_method_type === "us_bank_account" ? "ACH" : "Card"}
</Badge>
<span className="text-2xs text-muted-foreground">
{e.associations?.name}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7 px-2"
onClick={() => cancelEnrollment(e.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Process Autopay Dialog */}
<Dialog open={processDialogOpen} onOpenChange={setProcessDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Process Autopay Charges</DialogTitle>
<DialogDescription>
Charge all enrolled members for the selected association
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Association</Label>
<Select value={selectedAssociation} onValueChange={setSelectedAssociation}>
<SelectTrigger>
<SelectValue placeholder="Select association" />
</SelectTrigger>
<SelectContent>
{associations.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedAssociation && (
<p className="text-xs text-muted-foreground">
{filteredEnrollments.length} enrolled member{filteredEnrollments.length !== 1 ? "s" : ""} will be charged
</p>
)}
</div>
<div className="space-y-2">
<Label>Amount ($)</Label>
<Input
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
value={amountDollars}
onChange={(e) => setAmountDollars(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Monthly Assessment"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setProcessDialogOpen(false)}>
Cancel
</Button>
<Button onClick={processAutopay} disabled={processing}>
{processing ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Play className="h-4 w-4 mr-2" />
)}
Charge All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}