Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
f905abb05b
commit
c2ebdf2fe7
@@ -0,0 +1,313 @@
|
||||
// Messages tab on the case detail page. Lists incoming emails attached to
|
||||
// this case, with body preview and "save attachment to case" action.
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, Mail, Paperclip, Save, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
interface AttachmentMeta {
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_path: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
received_at: string;
|
||||
from_address: string | null;
|
||||
from_name: string | null;
|
||||
to_addresses: string[];
|
||||
cc_addresses: string[];
|
||||
subject: string | null;
|
||||
body_text: string | null;
|
||||
body_html: string | null;
|
||||
match_status: string;
|
||||
match_reason: string | null;
|
||||
attachments: AttachmentMeta[];
|
||||
}
|
||||
|
||||
export function CaseMessagesTab({ caseId }: { caseId: string }) {
|
||||
const [emails, setEmails] = useState<MessageRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [savingPath, setSavingPath] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("incoming_emails")
|
||||
.select(
|
||||
"id, received_at, from_address, from_name, to_addresses, cc_addresses, subject, body_text, body_html, match_status, match_reason, attachments",
|
||||
)
|
||||
.eq("case_id", caseId)
|
||||
.order("received_at", { ascending: false });
|
||||
if (error) {
|
||||
toast.error("Failed to load messages", { description: error.message });
|
||||
} else {
|
||||
const rows = ((data ?? []) as any[]).map((r) => ({
|
||||
...r,
|
||||
attachments: Array.isArray(r.attachments) ? r.attachments : [],
|
||||
})) as MessageRow[];
|
||||
setEmails(rows);
|
||||
if (rows.length > 0 && !selectedId) setSelectedId(rows[0].id);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [caseId, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [caseId]);
|
||||
|
||||
const detach = async (id: string) => {
|
||||
if (!confirm("Detach this email from the case? It will go back to the inbox.")) return;
|
||||
const { error } = await supabase
|
||||
.from("incoming_emails")
|
||||
.update({ case_id: null, match_status: "unmatched", matched_at: null })
|
||||
.eq("id", id);
|
||||
if (error) {
|
||||
toast.error("Could not detach", { description: error.message });
|
||||
} else {
|
||||
toast.success("Detached from case");
|
||||
setEmails((prev) => prev.filter((e) => e.id !== id));
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAttachment = async (att: AttachmentMeta) => {
|
||||
setSavingPath(att.storage_path);
|
||||
try {
|
||||
// Download the bytes from the email-attachments bucket
|
||||
const { data: blob, error: dlErr } = await supabase.storage
|
||||
.from("email-attachments")
|
||||
.download(att.storage_path);
|
||||
if (dlErr || !blob) {
|
||||
toast.error("Could not load attachment", { description: dlErr?.message });
|
||||
return;
|
||||
}
|
||||
// Upload into case-documents under this case
|
||||
const targetPath = `${caseId}/email/${Date.now()}-${att.filename.replace(/[^\w.\-]+/g, "_")}`;
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from("case-documents")
|
||||
.upload(targetPath, blob, { contentType: att.mime_type, upsert: false });
|
||||
if (upErr) {
|
||||
toast.error("Upload failed", { description: upErr.message });
|
||||
return;
|
||||
}
|
||||
const { error: insErr } = await supabase.from("documents").insert({
|
||||
case_id: caseId,
|
||||
name: att.filename,
|
||||
storage_path: targetPath,
|
||||
mime_type: att.mime_type,
|
||||
size_bytes: att.size_bytes,
|
||||
folder: "email",
|
||||
description: "Saved from email",
|
||||
});
|
||||
if (insErr) {
|
||||
toast.error("Saved file but couldn't index it", { description: insErr.message });
|
||||
return;
|
||||
}
|
||||
toast.success(`Saved ${att.filename} to Documents`);
|
||||
} finally {
|
||||
setSavingPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachment = async (att: AttachmentMeta) => {
|
||||
const { data, error } = await supabase.storage
|
||||
.from("email-attachments")
|
||||
.createSignedUrl(att.storage_path, 60 * 5);
|
||||
if (error || !data) {
|
||||
toast.error("Could not open", { description: error?.message });
|
||||
return;
|
||||
}
|
||||
window.open(data.signedUrl, "_blank");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
<Mail className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
No emails attached to this case yet. Incoming emails matching this
|
||||
case's contacts, case number, or thread will appear here automatically.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = emails.find((e) => e.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<div className="divide-y max-h-[70vh] overflow-y-auto">
|
||||
{emails.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(e.id)}
|
||||
className={`w-full text-left px-3 py-3 hover:bg-muted/50 transition-colors ${
|
||||
selectedId === e.id ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm">
|
||||
{e.from_name || e.from_address || "Unknown sender"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(e.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm truncate mt-0.5">
|
||||
{e.subject || "(no subject)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{e.attachments.length > 0 && (
|
||||
<Badge variant="secondary" className="h-4 text-[9px] px-1.5">
|
||||
<Paperclip className="h-2.5 w-2.5 mr-0.5" />
|
||||
{e.attachments.length}
|
||||
</Badge>
|
||||
)}
|
||||
{e.match_status === "manual" && (
|
||||
<Badge variant="outline" className="h-4 text-[9px] px-1.5">
|
||||
Manual
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
{selected ? (
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-lg leading-tight">
|
||||
{selected.subject || "(no subject)"}
|
||||
</h2>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
From:{" "}
|
||||
<span className="text-foreground">
|
||||
{selected.from_name
|
||||
? `${selected.from_name} <${selected.from_address}>`
|
||||
: selected.from_address}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
To: {selected.to_addresses.join(", ")}
|
||||
</div>
|
||||
{selected.cc_addresses.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Cc: {selected.cc_addresses.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(selected.received_at).toLocaleString()}
|
||||
</div>
|
||||
{selected.match_reason && (
|
||||
<div className="text-xs text-muted-foreground italic mt-1">
|
||||
Matched: {selected.match_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => detach(selected.id)}
|
||||
>
|
||||
Detach
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selected.attachments.length > 0 && (
|
||||
<div className="border rounded-md p-2 space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Attachments
|
||||
</div>
|
||||
{selected.attachments.map((att) => (
|
||||
<div
|
||||
key={att.storage_path}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{att.filename}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatBytes(att.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openAttachment(att)}
|
||||
title="Open"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={savingPath === att.storage_path}
|
||||
onClick={() => saveAttachment(att)}
|
||||
title="Save to case Documents"
|
||||
>
|
||||
{savingPath === att.storage_path ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
{selected.body_html ? (
|
||||
<iframe
|
||||
title="Email body"
|
||||
srcDoc={selected.body_html}
|
||||
sandbox=""
|
||||
className="w-full min-h-[400px] border rounded bg-white"
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans">
|
||||
{selected.body_text || "(empty)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="py-16 text-center text-sm text-muted-foreground">
|
||||
Select a message to read.
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(b: number): string {
|
||||
if (!b) return "—";
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Re-runs case matching against incoming emails.
|
||||
// POST { ids?: string[] } -> rematch the listed email ids
|
||||
// POST {} -> rematch all currently unmatched, non-archived emails
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { supabaseAdmin } from "@/integrations/supabase/client.server";
|
||||
import { matchAndUpdateEmails } from "@/lib/email-matching.server";
|
||||
|
||||
export const Route = createFileRoute("/hooks/rematch-emails")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
const body = (await request.json()) as { ids?: string[] };
|
||||
if (Array.isArray(body?.ids)) ids = body.ids.filter((x) => typeof x === "string");
|
||||
} catch {
|
||||
/* no body is fine */
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("incoming_emails")
|
||||
.select("id")
|
||||
.is("case_id", null)
|
||||
.eq("is_archived", false)
|
||||
.order("received_at", { ascending: false })
|
||||
.limit(500);
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
ids = (data ?? []).map((r) => r.id);
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await matchAndUpdateEmails(ids);
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, scanned: ids.length, ...stats }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return new Response(JSON.stringify({ error: msg }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user