Files
acmcc/src/pages/ARCInboundEmailsPage.tsx
T
2026-06-01 20:19:26 -04:00

386 lines
17 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { Inbox, Mail, Paperclip, Sparkles, ArrowRight, Trash2, RefreshCw, ExternalLink } from "lucide-react";
import { format } from "date-fns";
type InboundRow = {
id: string;
from_email: string | null;
from_name: string | null;
to_email: string | null;
subject: string | null;
body_text: string | null;
body_html: string | null;
attachment_urls: Array<{ name: string; url: string; type: string; size: number }>;
ai_project_type: string | null;
ai_title: string | null;
ai_description: string | null;
ai_property_address: string | null;
status: string;
arc_application_id: string | null;
created_at: string;
};
export default function ARCInboundEmailsPage() {
const [rows, setRows] = useState<InboundRow[]>([]);
const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
const [units, setUnits] = useState<{ id: string; unit_number: string; address: string | null; association_id: string }[]>([]);
const [owners, setOwners] = useState<{ id: string; first_name: string; last_name: string; unit_id: string | null; association_id: string }[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("new");
const [selected, setSelected] = useState<InboundRow | null>(null);
const [convertOpen, setConvertOpen] = useState(false);
const [assocId, setAssocId] = useState("");
const [unitId, setUnitId] = useState("");
const [ownerId, setOwnerId] = useState("");
const [title, setTitle] = useState("");
const [projectType, setProjectType] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const { toast } = useToast();
const fetchData = useCallback(async () => {
setLoading(true);
const [{ data: inb }, { data: a }, { data: u }, { data: o }] = await Promise.all([
supabase.from("arc_inbound_emails").select("*").order("created_at", { ascending: false }),
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
supabase.from("units").select("id, unit_number, address, association_id").order("unit_number"),
supabase.from("owners").select("id, first_name, last_name, unit_id, association_id").eq("status", "active").order("last_name"),
]);
setRows((inb as unknown as InboundRow[]) || []);
setAssociations(a || []);
setUnits((u as any) || []);
setOwners((o as any) || []);
setLoading(false);
}, []);
useEffect(() => {
fetchData();
const channel = supabase
.channel("arc-inbound-emails")
.on("postgres_changes", { event: "*", schema: "public", table: "arc_inbound_emails" }, () => fetchData())
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [fetchData]);
const filtered = rows.filter(r => statusFilter === "all" || r.status === statusFilter);
const openConvert = (r: InboundRow) => {
setSelected(r);
setAssocId("");
setUnitId("");
setOwnerId("");
setTitle(r.ai_title || r.subject || "ARC Application");
setProjectType(r.ai_project_type || "");
setDescription(r.ai_description || r.body_text || "");
setConvertOpen(true);
};
const handleConvert = async () => {
if (!selected || !assocId) {
toast({ title: "Select an association", variant: "destructive" });
return;
}
setSubmitting(true);
try {
const { data: app, error: appErr } = await supabase
.from("arc_applications")
.insert({
association_id: assocId,
unit_id: unitId || null,
owner_id: ownerId || null,
title: title || "ARC Application",
project_type: projectType || null,
description,
status: "submitted",
})
.select("id")
.single();
if (appErr) throw appErr;
const { data: { user } } = await supabase.auth.getUser();
await supabase
.from("arc_inbound_emails")
.update({
status: "assigned",
arc_application_id: app!.id,
association_id: assocId,
assigned_by: user?.id ?? null,
assigned_at: new Date().toISOString(),
})
.eq("id", selected.id);
// mark form_inbox entry reviewed
await supabase
.from("form_inbox")
.update({ status: "reviewed", reviewed_by: user?.id ?? null, reviewed_at: new Date().toISOString() })
.eq("source_type", "arc_inbound_email")
.eq("source_id", selected.id);
toast({ title: "ARC application created" });
setConvertOpen(false);
fetchData();
} catch (e: any) {
toast({ title: "Failed to convert", description: e.message, variant: "destructive" });
} finally {
setSubmitting(false);
}
};
const handleDismiss = async (id: string) => {
if (!confirm("Dismiss this inbound email?")) return;
await supabase.from("arc_inbound_emails").update({ status: "dismissed" }).eq("id", id);
fetchData();
};
const filteredUnits = units.filter(u => !assocId || u.association_id === assocId);
const filteredOwners = owners.filter(o => (!assocId || o.association_id === assocId) && (!unitId || o.unit_id === unitId));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold flex items-center gap-2">
<Inbox className="h-6 w-6" /> ARC Inbound Emails
</h1>
<p className="text-sm text-muted-foreground">
ARC applications submitted via email. Review and assign to an association/unit.
</p>
</div>
<div className="flex items-center gap-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">New</SelectItem>
<SelectItem value="assigned">Assigned</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={fetchData}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Inbound</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Received</TableHead>
<TableHead>From</TableHead>
<TableHead>Subject / AI Title</TableHead>
<TableHead>Type</TableHead>
<TableHead>Attachments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">Loading</TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No inbound ARC emails</TableCell></TableRow>
) : filtered.map(r => (
<TableRow key={r.id}>
<TableCell className="text-xs">{format(new Date(r.created_at), "MMM d, p")}</TableCell>
<TableCell>
<div className="text-sm">{r.from_name || "—"}</div>
<div className="text-xs text-muted-foreground">{r.from_email}</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium">{r.ai_title || r.subject || "(no subject)"}</div>
{r.ai_title && r.subject && r.ai_title !== r.subject && (
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Mail className="h-3 w-3" /> {r.subject}
</div>
)}
</TableCell>
<TableCell>
{r.ai_project_type && (
<Badge variant="outline" className="gap-1">
<Sparkles className="h-3 w-3" /> {r.ai_project_type}
</Badge>
)}
</TableCell>
<TableCell>
{r.attachment_urls?.length > 0 && (
<div className="flex items-center gap-1 text-xs">
<Paperclip className="h-3 w-3" /> {r.attachment_urls.length}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={r.status === "new" ? "default" : "secondary"}>{r.status}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => setSelected(r)}>View</Button>
{r.status === "new" && (
<>
<Button size="sm" onClick={() => openConvert(r)}>
Convert <ArrowRight className="h-3 w-3 ml-1" />
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDismiss(r.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* View dialog */}
<Dialog open={!!selected && !convertOpen} onOpenChange={(o) => !o && setSelected(null)}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selected?.ai_title || selected?.subject || "Inbound ARC Email"}</DialogTitle>
<DialogDescription>
From {selected?.from_name || selected?.from_email} {selected && format(new Date(selected.created_at), "PPp")}
</DialogDescription>
</DialogHeader>
{selected && (
<div className="space-y-4">
{(selected.ai_title || selected.ai_project_type || selected.ai_description) && (
<Card className="bg-secondary/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Sparkles className="h-4 w-4" /> AI-extracted
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{selected.ai_title && <div><strong>Title:</strong> {selected.ai_title}</div>}
{selected.ai_project_type && <div><strong>Type:</strong> {selected.ai_project_type}</div>}
{selected.ai_property_address && <div><strong>Property:</strong> {selected.ai_property_address}</div>}
{selected.ai_description && <div><strong>Description:</strong> {selected.ai_description}</div>}
</CardContent>
</Card>
)}
<div>
<Label className="text-xs text-muted-foreground">Original message</Label>
<pre className="mt-1 p-3 bg-muted rounded text-xs whitespace-pre-wrap font-sans border-border border max-h-72 overflow-auto">
{selected.body_text || "(no plain text body)"}
</pre>
</div>
{selected.attachment_urls?.length > 0 && (
<div>
<Label className="text-xs text-muted-foreground">Attachments ({selected.attachment_urls.length})</Label>
<div className="mt-2 space-y-1">
{selected.attachment_urls.map((a, i) => (
<a key={i} href={a.url} target="_blank" rel="noreferrer"
className="flex items-center gap-2 text-sm text-primary hover:underline border border-border rounded px-3 py-2">
<Paperclip className="h-4 w-4" /> {a.name}
<ExternalLink className="h-3 w-3 ml-auto" />
</a>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
{selected?.status === "new" && (
<Button onClick={() => openConvert(selected!)}>
Convert to ARC Application <ArrowRight className="h-4 w-4 ml-1" />
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Convert dialog */}
<Dialog open={convertOpen} onOpenChange={setConvertOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Convert to ARC Application</DialogTitle>
<DialogDescription>
Assign this inbound email to an association and (optionally) a unit/owner. A new ARC application will be created.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Association *</Label>
<Select value={assocId} onValueChange={(v) => { setAssocId(v); setUnitId(""); setOwnerId(""); }}>
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Unit</Label>
<Select value={unitId} onValueChange={(v) => { setUnitId(v); setOwnerId(""); }} disabled={!assocId}>
<SelectTrigger><SelectValue placeholder="(optional)" /></SelectTrigger>
<SelectContent>
{filteredUnits.map(u => (
<SelectItem key={u.id} value={u.id}>{u.unit_number}{u.address ? ` — ${u.address}` : ""}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Owner</Label>
<Select value={ownerId} onValueChange={setOwnerId} disabled={!assocId}>
<SelectTrigger><SelectValue placeholder="(optional)" /></SelectTrigger>
<SelectContent>
{filteredOwners.map(o => (
<SelectItem key={o.id} value={o.id}>{o.first_name} {o.last_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Title *</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<Label>Project type</Label>
<Input value={projectType} onChange={(e) => setProjectType(e.target.value)} placeholder="e.g. Paint, Fence" />
</div>
<div>
<Label>Description</Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
</div>
{selected && selected.attachment_urls?.length > 0 && (
<div className="text-xs text-muted-foreground">
{selected.attachment_urls.length} attachment{selected.attachment_urls.length > 1 ? "s" : ""} from the email will remain accessible from the inbound record.
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConvertOpen(false)}>Cancel</Button>
<Button onClick={handleConvert} disabled={submitting || !assocId || !title}>
{submitting ? "Creating…" : "Create ARC Application"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}