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>
459 lines
22 KiB
TypeScript
459 lines
22 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Megaphone, Plus, Pencil, Trash2, Send, RefreshCw, Search, Loader2, Users, Paperclip, Upload, X, Eye } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { useFileUpload } from "@/hooks/useFileUpload";
|
|
|
|
// Reusable Email Chip Input for CC/BCC
|
|
const EmailChipInput = ({ emails, setEmails, placeholder, label, helpText }: { emails: string[]; setEmails: (v: string[]) => void; placeholder?: string; label: string; helpText?: string }) => {
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [error, setError] = useState("");
|
|
const validateEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
const processInput = (value: string) => {
|
|
const splitEmails = value.split(",").map(e => e.trim()).filter(Boolean);
|
|
const newValid: string[] = [];
|
|
let hasErr = false;
|
|
splitEmails.forEach(email => {
|
|
if (validateEmail(email)) { if (!emails.includes(email)) newValid.push(email); }
|
|
else hasErr = true;
|
|
});
|
|
if (newValid.length > 0) setEmails([...emails, ...newValid]);
|
|
if (hasErr) setError("One or more email addresses are invalid.");
|
|
else { setError(""); setInputValue(""); }
|
|
};
|
|
const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); if (inputValue.trim()) processInput(inputValue); } };
|
|
const handleBlur = () => { if (inputValue.trim()) processInput(inputValue); };
|
|
const removeEmail = (em: string) => { setEmails(emails.filter(e => e !== em)); setError(""); };
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-baseline justify-between">
|
|
<Label>{label}</Label>
|
|
{helpText && <span className="text-xs text-muted-foreground">{helpText}</span>}
|
|
</div>
|
|
<div className={`flex flex-wrap gap-2 p-2 border rounded-md bg-background min-h-[40px] items-center focus-within:ring-2 focus-within:ring-ring ${error ? "border-destructive" : "border-input"}`}>
|
|
{emails.map(email => (
|
|
<Badge key={email} variant="secondary" className="flex items-center gap-1 py-1 px-2 text-sm">
|
|
{email}
|
|
<button type="button" className="ml-1 text-muted-foreground hover:text-foreground" onClick={() => removeEmail(email)}><X className="w-3 h-3" /></button>
|
|
</Badge>
|
|
))}
|
|
<input type="text" value={inputValue} onChange={e => setInputValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleBlur}
|
|
placeholder={emails.length === 0 ? placeholder : ""} className="flex-1 outline-none bg-transparent min-w-[200px] text-sm text-foreground placeholder:text-muted-foreground" />
|
|
</div>
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function SendBoardDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
|
const { user } = useAuth();
|
|
const { toast } = useToast();
|
|
const { uploadFiles, uploading } = useFileUpload();
|
|
|
|
const [associations, setAssociations] = useState<any[]>([]);
|
|
const [selectedAssocId, setSelectedAssocId] = useState("");
|
|
const [boardMembers, setBoardMembers] = useState<any[]>([]);
|
|
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
|
|
const [templates, setTemplates] = useState<any[]>([]);
|
|
const [selectedTemplate, setSelectedTemplate] = useState("none");
|
|
const [senders, setSenders] = useState<any[]>([]);
|
|
const [senderId, setSenderId] = useState("");
|
|
const [subject, setSubject] = useState("");
|
|
const [body, setBody] = useState("");
|
|
const [ccEmails, setCcEmails] = useState<string[]>([]);
|
|
const [bccEmails, setBccEmails] = useState<string[]>([]);
|
|
const [attachments, setAttachments] = useState<any[]>([]);
|
|
const [sending, setSending] = useState(false);
|
|
const [loadingMembers, setLoadingMembers] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
fetchAssociations();
|
|
fetchSenders();
|
|
fetchTemplates();
|
|
setSelectedAssocId("");
|
|
setBoardMembers([]);
|
|
setSelectedMembers([]);
|
|
setSelectedTemplate("none");
|
|
setSubject("");
|
|
setBody("");
|
|
setCcEmails([]);
|
|
setBccEmails([]);
|
|
setAttachments([]);
|
|
}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (selectedAssocId) fetchBoardMembers(selectedAssocId);
|
|
else { setBoardMembers([]); setSelectedMembers([]); }
|
|
}, [selectedAssocId]);
|
|
|
|
const fetchAssociations = async () => {
|
|
const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
|
|
setAssociations(data || []);
|
|
};
|
|
|
|
const fetchSenders = async () => {
|
|
const { data: result } = await supabase.functions.invoke("send-smtp-email", { body: { action: "list_senders" } });
|
|
const available = result?.senders || [];
|
|
setSenders(available);
|
|
if (available.length > 0) setSenderId(available.find((s: any) => s.is_default)?.id || available[0].id);
|
|
};
|
|
|
|
const fetchTemplates = async () => {
|
|
const { data } = await supabase.from("notify_board_templates").select("*").order("name");
|
|
setTemplates(data || []);
|
|
};
|
|
|
|
const fetchBoardMembers = async (assocId: string) => {
|
|
setLoadingMembers(true);
|
|
const { data } = await supabase.from("board_members").select("*").eq("association_id", assocId);
|
|
const members = (data || []).filter((m: any) => m.member_email);
|
|
setBoardMembers(members);
|
|
setSelectedMembers(members.map((m: any) => m.id));
|
|
setLoadingMembers(false);
|
|
};
|
|
|
|
const handleTemplateChange = (val: string) => {
|
|
setSelectedTemplate(val);
|
|
if (val !== "none") {
|
|
const tmpl = templates.find((t) => t.id === val);
|
|
if (tmpl) {
|
|
setSubject(tmpl.subject || "");
|
|
setBody(tmpl.body || "");
|
|
}
|
|
}
|
|
};
|
|
|
|
const toggleMember = (id: string) => {
|
|
setSelectedMembers((prev) => prev.includes(id) ? prev.filter((m) => m !== id) : [...prev, id]);
|
|
};
|
|
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length === 0) return;
|
|
const uploaded = await uploadFiles(files, 'files', 'notify-board');
|
|
if (uploaded.length > 0) {
|
|
setAttachments((prev) => [...prev, ...uploaded]);
|
|
toast({ title: "Attachments added", description: `${uploaded.length} file(s) attached.` });
|
|
}
|
|
e.target.value = "";
|
|
};
|
|
|
|
const removeAttachment = (idx: number) => {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (!subject.trim() || !body.trim()) {
|
|
toast({ variant: "destructive", title: "Validation Error", description: "Subject and body are required." });
|
|
return;
|
|
}
|
|
if (selectedMembers.length === 0) {
|
|
toast({ variant: "destructive", title: "No Recipients", description: "Select at least one board member." });
|
|
return;
|
|
}
|
|
if (!senderId) {
|
|
toast({ variant: "destructive", title: "No Sender", description: "Please select an email sender." });
|
|
return;
|
|
}
|
|
|
|
setSending(true);
|
|
const recipients = boardMembers.filter((m) => selectedMembers.includes(m.id));
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
// Send a single email TO the sender's email, BCC all board member emails
|
|
const selectedSender = senders.find((s: any) => s.id === senderId);
|
|
const senderEmailAddr = selectedSender?.email_address;
|
|
const memberEmails = recipients.map((m: any) => m.member_email);
|
|
const combinedBcc = [...memberEmails, ...bccEmails];
|
|
|
|
try {
|
|
const { data, error } = await supabase.functions.invoke("send-smtp-email", {
|
|
body: {
|
|
sender_id: senderId,
|
|
recipient: senderEmailAddr,
|
|
subject,
|
|
html: body.replace(/\n/g, "<br/>"),
|
|
feature_type: "notify_board",
|
|
cc: ccEmails.length > 0 ? ccEmails : undefined,
|
|
bcc: combinedBcc.length > 0 ? combinedBcc : undefined,
|
|
attachments: attachments.length > 0 ? attachments.map(a => ({ filename: a.name, path: a.url })) : undefined,
|
|
},
|
|
});
|
|
if (error || !data?.success) failCount = recipients.length;
|
|
else successCount = recipients.length;
|
|
} catch {
|
|
failCount = recipients.length;
|
|
}
|
|
|
|
setSending(false);
|
|
if (successCount > 0) {
|
|
toast({ title: "Sent", description: `Delivered to ${successCount} board member(s).${failCount > 0 ? ` ${failCount} failed.` : ""}` });
|
|
onOpenChange(false);
|
|
} else {
|
|
toast({ variant: "destructive", title: "Failed", description: "All emails failed to send." });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Send Board Notification</DialogTitle>
|
|
<DialogDescription>Select an association and board members to notify.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Association</Label>
|
|
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
|
|
<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="space-y-2">
|
|
<Label>From Sender</Label>
|
|
<Select value={senderId} onValueChange={setSenderId}>
|
|
<SelectTrigger><SelectValue placeholder="Select Sender" /></SelectTrigger>
|
|
<SelectContent>
|
|
{senders.map((s: any) => <SelectItem key={s.id} value={s.id}>{s.sender_name} ({s.email_address})</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedAssocId && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Board Members</Label>
|
|
<span className="text-xs text-muted-foreground">{selectedMembers.length} of {boardMembers.length} selected</span>
|
|
</div>
|
|
{loadingMembers ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-3"><Loader2 className="w-4 h-4 animate-spin" /> Loading...</div>
|
|
) : boardMembers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">No board members with email found for this association.</p>
|
|
) : (
|
|
<div className="border rounded-md max-h-[140px] overflow-y-auto">
|
|
{boardMembers.map((m: any) => (
|
|
<label key={m.id} className="flex items-center gap-3 px-3 py-2 hover:bg-accent cursor-pointer text-sm border-b last:border-b-0">
|
|
<Checkbox checked={selectedMembers.includes(m.id)} onCheckedChange={() => toggleMember(m.id)} />
|
|
<div className="flex-1 min-w-0">
|
|
<span className="font-medium">{m.member_name}</span>
|
|
{m.role && <span className="text-muted-foreground ml-2">({m.role})</span>}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground truncate">{m.member_email}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label>Apply Template (Optional)</Label>
|
|
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
|
|
<SelectTrigger><SelectValue placeholder="Write your own message" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">-- No Template --</SelectItem>
|
|
{templates.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<EmailChipInput label="CC (Carbon Copy)" emails={ccEmails} setEmails={setCcEmails} placeholder="email@example.com" helpText="Press Enter or comma to add" />
|
|
<EmailChipInput label="BCC (Blind Carbon Copy)" emails={bccEmails} setEmails={setBccEmails} placeholder="email@example.com" helpText="Recipients won't see these" />
|
|
|
|
<div className="space-y-2">
|
|
<Label>Subject</Label>
|
|
<Input placeholder="Email subject..." value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Message Body</Label>
|
|
<Textarea placeholder="Write your message here..." className="min-h-[150px]" value={body} onChange={(e) => setBody(e.target.value)} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Attachments</Label>
|
|
<span className="text-xs text-muted-foreground">Max 25MB per file</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button type="button" variant="outline" size="sm" disabled={uploading} onClick={() => document.getElementById("notify-board-file-input")?.click()}>
|
|
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Paperclip className="w-4 h-4 mr-2" />}
|
|
{uploading ? "Uploading..." : "Attach Files"}
|
|
</Button>
|
|
<input id="notify-board-file-input" type="file" multiple className="hidden" onChange={handleFileSelect} />
|
|
</div>
|
|
{attachments.length > 0 && (
|
|
<div className="space-y-1 mt-2">
|
|
{attachments.map((att, idx) => (
|
|
<div key={idx} className="flex items-center justify-between gap-2 px-3 py-2 bg-muted rounded-md text-sm">
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
<span className="truncate">{att.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">({(att.size / 1024).toFixed(1)} KB)</span>
|
|
</div>
|
|
<button type="button" onClick={() => removeAttachment(idx)} className="text-muted-foreground hover:text-destructive shrink-0">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
<Button onClick={handleSend} disabled={sending || !subject || !body || !senderId || selectedMembers.length === 0}>
|
|
{sending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
|
|
{sending ? "Sending..." : `Send to ${selectedMembers.length} Member${selectedMembers.length !== 1 ? "s" : ""}`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export default function NotifyBoardPage() {
|
|
const { user } = useAuth();
|
|
const { toast } = useToast();
|
|
const [templates, setTemplates] = useState<any[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [showSend, setShowSend] = useState(false);
|
|
const [editId, setEditId] = useState<string | null>(null);
|
|
const [form, setForm] = useState({ name: "", subject: "", body: "" });
|
|
|
|
useEffect(() => { fetchTemplates(); }, []);
|
|
|
|
const fetchTemplates = async () => {
|
|
const { data } = await supabase.from("notify_board_templates").select("*").order("created_at", { ascending: false });
|
|
setTemplates(data || []);
|
|
};
|
|
|
|
const openNew = () => { setEditId(null); setForm({ name: "", subject: "", body: "" }); setShowCreate(true); };
|
|
const openEdit = (t: any) => { setEditId(t.id); setForm({ name: t.name, subject: t.subject || "", body: t.body || "" }); setShowCreate(true); };
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name.trim()) { toast({ variant: "destructive", title: "Error", description: "Name required." }); return; }
|
|
if (editId) {
|
|
await supabase.from("notify_board_templates").update(form).eq("id", editId);
|
|
} else {
|
|
await supabase.from("notify_board_templates").insert({ ...form, created_by: user?.id });
|
|
}
|
|
toast({ title: "Saved" }); setShowCreate(false); fetchTemplates();
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
await supabase.from("notify_board_templates").delete().eq("id", id);
|
|
toast({ title: "Deleted" }); fetchTemplates();
|
|
};
|
|
|
|
const filtered = templates.filter((t) => t.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Megaphone className="h-7 w-7 text-primary" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">Notify Board</h1>
|
|
<p className="text-sm text-muted-foreground">Manage templates and send notifications to association board members.</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="icon" onClick={fetchTemplates}><RefreshCw className="h-4 w-4" /></Button>
|
|
<Button onClick={() => setShowSend(true)}><Send className="h-4 w-4 mr-2" /> Send Notification</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="templates">
|
|
<TabsList>
|
|
<TabsTrigger value="templates">Templates</TabsTrigger>
|
|
<TabsTrigger value="history" disabled>History (Coming Soon)</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="templates" className="space-y-4 mt-4">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">Email Templates</h2>
|
|
<p className="text-sm text-muted-foreground">Create and manage standard templates for recurring board communications.</p>
|
|
</div>
|
|
<Button onClick={openNew}><Plus className="h-4 w-4 mr-2" /> Create New Template</Button>
|
|
</div>
|
|
<div className="max-w-sm">
|
|
<Input placeholder="Search templates..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-9" />
|
|
</div>
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Subject</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.length === 0 ? (
|
|
<TableRow><TableCell colSpan={4} className="text-center py-12 text-muted-foreground">No templates found.</TableCell></TableRow>
|
|
) : (
|
|
filtered.map((t) => (
|
|
<TableRow key={t.id}>
|
|
<TableCell className="font-medium">{t.name}</TableCell>
|
|
<TableCell className="max-w-[300px] truncate">{t.subject || "—"}</TableCell>
|
|
<TableCell>{new Date(t.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon" onClick={() => openEdit(t)}><Pencil className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => handleDelete(t.id)}><Trash2 className="h-4 w-4" /></Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>{editId ? "Edit Template" : "Create Template"}</DialogTitle></DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2"><Label>Template Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
|
<div className="space-y-2"><Label>Subject</Label><Input value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} /></div>
|
|
<div className="space-y-2"><Label>Body</Label><Textarea className="min-h-[150px]" value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} /></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
|
<Button onClick={handleSave}>Save</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<SendBoardDialog open={showSend} onOpenChange={setShowSend} />
|
|
</div>
|
|
);
|
|
}
|