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:
gpt-engineer-app[bot]
2026-04-22 18:40:02 +00:00
co-authored by renee-png
4 changed files with 212 additions and 0 deletions
BIN
View File
Binary file not shown.
+11
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").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>
);
}
+9
View File
@@ -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>>
}
}