Added document sharing
X-Lovable-Edit-ID: edt-7f5cdc0c-0d9c-4aa9-afb8-fa241abaf38a Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ShareDocumentDialog } from "./share-document-dialog";
|
||||
|
||||
const MAX_BYTES = 150 * 1024 * 1024;
|
||||
|
||||
@@ -41,6 +43,7 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
|
||||
const [previewDoc, setPreviewDoc] = useState<any | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [shareDoc, setShareDoc] = useState<any | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -425,6 +428,7 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => openPreview(d)} title="Quick preview"><Eye className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => download(d)} title="Download"><Download className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShareDoc(d)} title="Share with contacts"><Share2 className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => del(d)} title="Delete"><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -461,6 +465,13 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ShareDocumentDialog
|
||||
open={!!shareDoc}
|
||||
onOpenChange={(o) => !o && setShareDoc(null)}
|
||||
caseId={caseId}
|
||||
doc={shareDoc}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Mail } from "lucide-react";
|
||||
|
||||
type Contact = { id: string; name: string; email: string | null; role: string | null };
|
||||
|
||||
export function ShareDocumentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
caseId,
|
||||
doc,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
caseId: string;
|
||||
doc: { id: string; name: string; storage_path: string } | null;
|
||||
}) {
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [extraEmail, setExtraEmail] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !doc) return;
|
||||
setSelected({});
|
||||
setExtraEmail("");
|
||||
setSubject(`Shared document: ${doc.name}`);
|
||||
setMessage(`Please find the attached document: ${doc.name}.\n\nClick the link in the email to download. The link expires in 7 days.`);
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("case_contacts")
|
||||
.select("role, contact:contact_id(id, name, email)")
|
||||
.eq("case_id", caseId);
|
||||
setLoading(false);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
const list: Contact[] = (data ?? [])
|
||||
.map((row: any) => row.contact ? { ...row.contact, role: row.role } : null)
|
||||
.filter(Boolean);
|
||||
setContacts(list);
|
||||
})();
|
||||
}, [open, caseId, doc]);
|
||||
|
||||
const toggle = (id: string) => setSelected((s) => ({ ...s, [id]: !s[id] }));
|
||||
|
||||
const send = async () => {
|
||||
if (!doc) return;
|
||||
const recipients = contacts.filter((c) => selected[c.id] && c.email).map((c) => c.email!);
|
||||
const extras = extraEmail.split(/[,;\s]+/).map((s) => s.trim()).filter((s) => s.includes("@"));
|
||||
const allRecipients = Array.from(new Set([...recipients, ...extras]));
|
||||
if (allRecipients.length === 0) { toast.error("Pick at least one contact with an email, or enter an address"); return; }
|
||||
|
||||
setSending(true);
|
||||
// 7 days = 604800 seconds (max for signed URLs)
|
||||
const { data: signed, error: signErr } = await supabase.storage
|
||||
.from("case-documents")
|
||||
.createSignedUrl(doc.storage_path, 60 * 60 * 24 * 7);
|
||||
if (signErr || !signed) {
|
||||
setSending(false);
|
||||
toast.error(signErr?.message || "Could not create download link");
|
||||
return;
|
||||
}
|
||||
|
||||
const safeMsg = (message || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\n/g, "<br/>");
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #111;">
|
||||
<p>${safeMsg}</p>
|
||||
<p style="margin-top:18px;">
|
||||
<a href="${signed.signedUrl}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;">
|
||||
Download ${doc.name}
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:12px;color:#666;margin-top:18px;">
|
||||
Or copy this link into your browser:<br/>
|
||||
<a href="${signed.signedUrl}">${signed.signedUrl}</a>
|
||||
</p>
|
||||
<p style="font-size:12px;color:#666;">This download link expires in 7 days.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { error } = await supabase.functions.invoke("send-smtp-email", {
|
||||
body: {
|
||||
to: allRecipients,
|
||||
subject: subject || `Shared document: ${doc.name}`,
|
||||
html,
|
||||
context: "document_share",
|
||||
case_id: caseId,
|
||||
},
|
||||
});
|
||||
setSending(false);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success(`Document shared with ${allRecipients.length} recipient${allRecipients.length > 1 ? "s" : ""}`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Mail className="h-4 w-4" /> Share document</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
<span className="font-medium text-foreground">{doc?.name}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Case contacts</Label>
|
||||
<div className="mt-2 max-h-56 overflow-y-auto border rounded-md divide-y">
|
||||
{loading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading contacts…
|
||||
</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No contacts linked to this case.</div>
|
||||
) : (
|
||||
contacts.map((c) => {
|
||||
const hasEmail = !!c.email;
|
||||
return (
|
||||
<label
|
||||
key={c.id}
|
||||
className={`flex items-center gap-3 px-3 py-2 ${hasEmail ? "hover:bg-muted/40 cursor-pointer" : "opacity-60 cursor-not-allowed"}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!!selected[c.id]}
|
||||
onCheckedChange={() => hasEmail && toggle(c.id)}
|
||||
disabled={!hasEmail}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{c.name}{c.role ? <span className="text-xs text-muted-foreground font-normal"> · {c.role}</span> : null}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{c.email || "No email on file"}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="extra-email" className="text-xs uppercase tracking-wider text-muted-foreground">Additional emails</Label>
|
||||
<Input
|
||||
id="extra-email"
|
||||
value={extraEmail}
|
||||
onChange={(e) => setExtraEmail(e.target.value)}
|
||||
placeholder="comma-separated, optional"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="share-subject" className="text-xs uppercase tracking-wider text-muted-foreground">Subject</Label>
|
||||
<Input
|
||||
id="share-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="share-msg" className="text-xs uppercase tracking-wider text-muted-foreground">Message</Label>
|
||||
<Textarea
|
||||
id="share-msg"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={sending}>Cancel</Button>
|
||||
<Button onClick={send} disabled={sending}>
|
||||
{sending ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Mail className="h-4 w-4 mr-1.5" />}
|
||||
Send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1077,3 +1077,12 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user