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>
386 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|