mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
9.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|