From 0b8a20cc2948da147288df1e79ed2d20bcbc30a6 Mon Sep 17 00:00:00 2001 From: Gabe Date: Wed, 15 Jul 2026 01:59:48 +0000 Subject: [PATCH] Add Ticketing_Containers/pepperminto-enhanced/index.tsx --- .../pepperminto-enhanced/index.tsx | 2274 +++++++++++++++++ 1 file changed, 2274 insertions(+) create mode 100644 Ticketing_Containers/pepperminto-enhanced/index.tsx diff --git a/Ticketing_Containers/pepperminto-enhanced/index.tsx b/Ticketing_Containers/pepperminto-enhanced/index.tsx new file mode 100644 index 0000000..67eda08 --- /dev/null +++ b/Ticketing_Containers/pepperminto-enhanced/index.tsx @@ -0,0 +1,2274 @@ +// @ts-nocheck +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/shadcn/ui/command"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; +import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { getCookie } from "cookies-next"; +import moment from "moment"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import TicketDetailContent from "../TicketDetailContent"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; + +import { toast } from "@/shadcn/hooks/use-toast"; +import { hasAccess } from "@/shadcn/lib/hasAccess"; +import { cn } from "@/shadcn/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar"; +import { Button } from "@/shadcn/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shadcn/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; +import { Switch } from "@/shadcn/ui/switch"; +import { Input } from "@/shadcn/ui/input"; +import { + CheckIcon, + CircleCheck, + CircleDotDashed, + Clock, + Download, + Ellipsis, + Eye, + EyeOff, + LifeBuoy, + Loader, + LoaderCircle, + Lock, + Paperclip, + PanelTopClose, + Play, + SignalHigh, + SignalLow, + SignalMedium, + Trash2, + Unlock, + X, +} from "lucide-react"; +import { useUser } from "../../store/session"; +import { ClientCombo, IconCombo, UserCombo } from "../Combo"; +import { useTicketActions } from "@/shadcn/hooks/useTicketActions"; + +const ticketStatusMap = [ + { id: 0, value: "hold", name: "Hold", icon: CircleDotDashed }, + { id: 1, value: "needs_support", name: "Needs Support", icon: LifeBuoy }, + { id: 2, value: "in_progress", name: "In Progress", icon: CircleDotDashed }, + { id: 3, value: "in_review", name: "In Review", icon: Loader }, + { id: 4, value: "done", name: "Done", icon: CircleCheck }, +]; + +const getStatusDisplayName = (status?: string | null) => { + if (!status) return ""; + const match = ticketStatusMap.find((s) => s.value === status); + return match ? match.name : status; +}; + +const priorityOptions = [ + { + id: "1", + name: "Low", + value: "low", + icon: SignalLow, + }, + { + id: "2", + name: "Medium", + value: "medium", + icon: SignalMedium, + }, + { + id: "1", + name: "High", + value: "high", + icon: SignalHigh, + }, +]; + +export default function Ticket({ variant }: { variant?: "dashboard" | "portal" }) { + const router = useRouter(); + const { t } = useTranslation("peppermint"); + const { theme } = useTheme(); + + const rawToken = getCookie("session"); + const token = typeof rawToken === "string" ? rawToken : ""; + + const { user } = useUser(); + + const fetchTicketById = async () => { + const id = router.query.id; + const res = await fetch(`/api/v1/ticket/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + hasAccess(res); + + return res.json(); + }; + + const { data, isLoading, isError, isSuccess, refetch } = useQuery({ + queryKey: ["fetchTickets", router.query.id], + queryFn: fetchTicketById, + // Automatically start fetching when we have an id + token, + // and keep polling every 3s for new comments/changes. + enabled: Boolean(router.query.id && token), + refetchInterval: 3000, + }); + + const fetchTicketFiles = async () => { + const id = router.query.id; + const res = await fetch(`/api/v1/storage/ticket/${id}/files`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return res.json(); + }; + + const { data: filesData, refetch: refetchFiles } = useQuery({ + queryKey: ["ticketFiles", router.query.id], + queryFn: fetchTicketFiles, + enabled: Boolean(router.query.id && token), + refetchInterval: 3000, + }); + + useEffect(() => { + if (!router.query.id) return; + refetch(); + refetchFiles(); + }, [router.query.id]); + + const [edit, setEdit] = useState(false); + const [editTime, setTimeEdit] = useState(false); + const [assignedEdit, setAssignedEdit] = useState(false); + const [labelEdit, setLabelEdit] = useState(false); + + const [users, setUsers] = useState(); + const [clients, setClients] = useState(); + const [n, setN] = useState(); + + const [note, setNote] = useState(); + const [issue, setIssue] = useState(); + const [title, setTitle] = useState(); + // const [uploaded, setUploaded] = useState(); + const [priority, setPriority] = useState(); + const [ticketStatus, setTicketStatus] = useState(); + const [comment, setComment] = useState(); + const [timeSpent, setTimeSpent] = useState(); + const [timeReason, setTimeReason] = useState(""); + const [files, setFiles] = useState([]); + const [pendingPreviews, setPendingPreviews] = useState< + { name: string; type: string; url: string }[] + >([]); + const [assignedClient, setAssignedClient] = useState(); + const messagesRef = useRef(null); + const [previewAttachment, setPreviewAttachment] = useState<{ + url: string; + filename?: string; + mime?: string; + size?: number; + kind: "image" | "video"; + } | null>(null); + + const history = useRouter(); + + const { id } = history.query; + + const { deleteTicket } = useTicketActions(String(token || ""), refetch); + + const isPortal = variant === "portal"; + + async function update() { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id, + detail: JSON.stringify(debouncedValue), + note, + title: debounceTitle, + priority: priority?.value, + status: ticketStatus?.value, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update ticket", + }); + return; + } + setEdit(false); + } + + async function updateStatus() { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + status: !data.ticket.isComplete, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update status", + }); + return; + } + refetch(); + } + + async function hide(hidden) { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/status/hide`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + hidden, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update visibility", + }); + return; + } + refetch(); + } + + async function lock(locked) { + const res = await fetch(`/api/v1/ticket/status/lock`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + locked, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update lock status", + }); + return; + } + refetch(); + } + + async function deleteIssue() { + await fetch(`/api/v1/ticket/delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + toast({ + variant: "default", + title: "Issue Deleted", + description: "The issue has been deleted", + }); + router.push("/issues"); + } + }); + } + + async function addComment(): Promise { + if (data && data.ticket && data.ticket.locked) return false; + + const text = String(comment || "").trim(); + if (!text) return true; + + const res = await fetch(`/api/v1/ticket/comment`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + text, + id, + public: false, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to add comment", + }); + return false; + } + refetch(); + return true; + } + + async function deleteComment(id: string) { + await fetch(`/api/v1/ticket/comment/delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ id }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + refetch(); + } else { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to delete comment", + }); + } + }); + } + + async function addTime() { + if (data && data.ticket && data.ticket.locked) return; + + if (!timeSpent || Number(timeSpent) <= 0) { + toast({ + variant: "destructive", + title: "Enter a time", + description: "Please enter the number of minutes spent.", + }); + return; + } + + await fetch(`/api/v1/time/new`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + time: timeSpent, + ticket: router.query.id, + title: timeReason, + user: user.id, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + setTimeEdit(false); + setTimeSpent(""); + setTimeReason(""); + refetch(); + toast({ + variant: "default", + title: "Time Added", + description: "Time has been added to the ticket", + }); + } else { + toast({ + variant: "destructive", + title: "Failed to add time", + description: "Please try again.", + }); + } + }) + .catch(() => { + toast({ + variant: "destructive", + title: "Failed to add time", + description: "Please try again.", + }); + }); + } + + async function fetchUsers() { + const res = await fetch(`/api/v1/users/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to fetch users", + }); + return; + } + + if (res.users) { + setUsers(res.users); + } + } + + async function fetchClients() { + const res = await fetch(`/api/v1/clients/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + + if (res?.success && Array.isArray(res.clients)) { + setClients(res.clients); + return; + } + + // Don't hard-fail the ticket view if client list isn't available. + setClients([]); + } + + async function subscribe() { + if (data && data.ticket && data.ticket.locked) return; + + const following = Array.isArray(data?.ticket?.following) + ? (data.ticket.following as string[]) + : []; + const isFollowing = following.includes(user.id); + const action = isFollowing ? "unsubscribe" : "subscribe"; + + const res = await fetch(`/api/v1/ticket/${action}/${id}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || `Failed to ${action} to issue`, + }); + return; + } + + toast({ + title: isFollowing ? "Unsubscribed" : "Subscribed", + description: isFollowing + ? "You will no longer receive updates" + : "You will now receive updates", + duration: 3000, + }); + + refetch(); + } + + async function transferTicket() { + if (data && data.ticket && data.ticket.locked) return; + if (n === undefined) return; + + const res = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: n ? n.id : undefined, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to transfer ticket", + }); + return; + } + + setAssignedEdit(false); + refetch(); + } + + async function transferClient() { + if (data && data.ticket && data.ticket.locked) return; + if (assignedClient === undefined) return; + + const res = await fetch(`/api/v1/ticket/transfer/client`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + client: assignedClient ? assignedClient.id : undefined, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to transfer client", + }); + return; + } + + setAssignedEdit(false); + refetch(); + } + + const handleFileChange = (e: React.ChangeEvent) => { + if (!e.target.files) return; + const incoming = Array.from(e.target.files); + + const maxBytes = 10 * 1024 * 1024; + const accepted: File[] = []; + + for (const candidate of incoming) { + const isVideo = + typeof candidate.type === "string" && + candidate.type.startsWith("video/"); + + if (!isVideo && candidate.size > maxBytes) { + const sizeMB = (candidate.size / (1024 * 1024)).toFixed(1); + toast({ + variant: "destructive", + title: "File too large", + description: `"${candidate.name}" is ${sizeMB} MB. Maximum file size is 10 MB.`, + }); + continue; + } + accepted.push(candidate); + } + + if (accepted.length === 0) { + e.target.value = ""; + return; + } + + setFiles((prev) => [...prev, ...accepted]); + e.target.value = ""; + }; + + const handleUpload = async (): Promise => { + if (files.length === 0) return true; + + if (!token) { + toast({ + variant: "destructive", + title: "Not logged in", + description: "Please log in again and retry the upload.", + }); + setFiles([]); + return false; + } + + try { + setIsUploading(true); + const failed: File[] = []; + + for (const f of files) { + const formData = new FormData(); + formData.append("file", f); + + const result = await fetch( + `/api/v1/storage/ticket/${router.query.id}/upload/single`, + { + method: "POST", + body: formData, + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const data = await result.json().catch(() => null); + if (!result.ok || !data?.success) { + failed.push(f); + } + } + + if (failed.length > 0) { + setFiles(failed); + toast({ + variant: "destructive", + title: "Some uploads failed", + description: `${failed.length} attachment(s) could not be uploaded. Please retry.`, + }); + return false; + } + + setFiles([]); + refetch(); + refetchFiles(); + return true; + } catch (error) { + toast({ + variant: "destructive", + title: "Upload error", + description: "An unexpected error occurred while uploading.", + }); + return false; + } finally { + setIsUploading(false); + } + }; + + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const handleButtonClick = () => { + fileInputRef.current?.click(); + }; + + const [enterToSend, setEnterToSend] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + setEnterToSend(localStorage.getItem("ticket_enter_to_send") === "true"); + }, []); + + // When viewing a ticket, mark all notifications for this ticket as read + useEffect(() => { + if (!id || !token) return; + const ticketId = String(id); + fetch(`/api/v1/user/notifications/ticket/read`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ ticketId }), + }).catch(() => { + // best-effort; ignore failures + }); + }, [id, token]); + + useEffect(() => { + if (!messagesRef.current) return; + const el = messagesRef.current; + el.scrollTop = el.scrollHeight; + }, [data?.ticket?.comments?.length, filesData?.files?.length]); + + useEffect(() => { + // build preview URLs for pending attachments + setPendingPreviews((prev) => { + prev.forEach((p) => { + try { + URL.revokeObjectURL(p.url); + } catch {} + }); + return []; + }); + + if (files.length === 0) return; + + const next = files.map((f) => ({ + name: f.name, + type: f.type || "application/octet-stream", + url: URL.createObjectURL(f), + })); + setPendingPreviews(next); + + return () => { + next.forEach((p) => { + try { + URL.revokeObjectURL(p.url); + } catch {} + }); + }; + }, [files]); + + useEffect(() => { + fetchUsers(); + fetchClients(); + }, []); + + useEffect(() => { + transferTicket(); + }, [n]); + + useEffect(() => { + transferClient(); + }, [assignedClient]); + + const [debouncedValue] = useDebounce(issue, 500); + const [debounceTitle] = useDebounce(title, 500); + + useEffect(() => { + update(); + }, [priority, ticketStatus, debounceTitle]); + + useEffect(() => { + if (issue) { + update(); + } + }, [debouncedValue]); + + async function handleSend() { + if (data && data.ticket && data.ticket.locked) return; + const hasText = String(comment || "").trim().length > 0; + const hasFiles = files.length > 0; + if (!hasText && !hasFiles) return; + + // Send text first so it appears above attachments. + if (hasText) { + const ok = await addComment(); + if (!ok) return; + setComment(""); + } + + if (hasFiles) { + const ok = await handleUpload(); + if (!ok) return; + } + } + + // Rich text editor (BlockNote) disabled in this build. + // Ticket details are shown in a read-only frame below. + + async function updateTicketStatus(e: any, ticket: any) { + await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id, status: !ticket.isComplete }), + }) + .then((res) => res.json()) + .then(() => { + toast({ + title: ticket.isComplete ? "Issue re-opened" : "Issue closed", + description: "The status of the issue has been updated.", + duration: 3000, + }); + refetch(); + }); + } + + // Add these new functions + async function updateTicketAssignee(ticketId: string, user: any) { + try { + const response = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: user ? user.id : undefined, + id: ticketId, + }), + }); + + if (!response.ok) throw new Error("Failed to update assignee"); + + toast({ + title: "Assignee updated", + description: `Transferred issue successfully`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update assignee", + variant: "destructive", + duration: 3000, + }); + } + } + + async function updateTicketPriority(ticket: any, priority: string) { + try { + const response = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: ticket.id, + detail: ticket.detail, + note: ticket.note, + title: ticket.title, + priority: priority, + status: ticket.status, + }), + }).then((res) => res.json()); + + if (!response.success) throw new Error("Failed to update priority"); + + toast({ + title: "Priority updated", + description: `Ticket priority set to ${priority}`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update priority", + variant: "destructive", + duration: 3000, + }); + } + } + + const priorities = ["low", "medium", "high"]; + + return ( +
+ {isLoading && ( +
+

Loading data ...

+ {/* */} +
+ )} + + {isError && ( +
+

Error fetching data ...

+
+ )} + + {isSuccess && ( + <> +
+
+
+
+
+
+

+ #{data.ticket.Number} - +

+ setTitle(e.target.value)} + key={data.ticket.id} + disabled={data.ticket.locked} + /> +
+
+
+ {data.ticket.client && ( +
+ + {data.ticket.client.name} + +
+ )} +
+ {!data.ticket.isComplete ? ( +
+ + {t("open_issue")} + +
+ ) : ( +
+ + {t("closed_issue")} + +
+ )} +
+
+ + {data.ticket.type} + +
+ {data.ticket.hidden && ( +
+ + Hidden + +
+ )} + {data.ticket.locked && ( +
+ + Locked + +
+ )} +
+ {user.isAdmin && ( + + + + + + + Issue Actions + + + {data.ticket.hidden ? ( + hide(false)} + > + + Show Issue + + ) : ( + hide(true)} + > + + Hide Issue + + )} + {data.ticket.locked ? ( + lock(false)} + > + + Unlock Issue + + ) : ( + lock(true)} + > + + Lock Issue + + )} + + deleteIssue()} + > + + Delete Issue + + + + )} +
+
+
+ {!isPortal && } +
+
+
+ + Activity + + +
+ + + {Array.isArray(data.ticket.following) && + (data.ticket.following as string[]).length > 0 && ( +
+ + + + + +
+ Followers + {Array.isArray(data.ticket.following) && + Array.isArray(users) && + (data.ticket.following as string[]).map( + (follower: any) => { + const userMatch = users.find( + (u: any) => + u.id === follower && + (!data.ticket.assignedTo || + u.id !== + data.ticket.assignedTo.id) + ); + return userMatch ? ( +
+ {userMatch.name} +
+ ) : null; + } + )} + + {Array.isArray(data.ticket.following) && + (!Array.isArray(users) || + (data.ticket.following as string[]).filter( + (follower: any) => + !data.ticket.assignedTo || + follower !== + data.ticket.assignedTo.id + ).length === 0) && ( + + This issue has no followers + + )} +
+
+
+
+ )} +
+
+
+
+ {data.ticket.fromImap ? ( + <> + + {data.ticket.email} + + created via email at + + {moment(data.ticket.createdAt).format( + "DD/MM/YYYY" + )} + + + ) : ( + <> + {data.ticket.createdBy ? ( +
+ + Created by + + {data.ticket.createdBy.name} + {" "} + at{" "} + + + {moment(data.ticket.createdAt).format( + "LLL" + )} + + {data.ticket.name && ( + + for {data.ticket.name} + + )} + {data.ticket.email && ( + + ( {data.ticket.email} ) + + )} +
+ ) : ( +
+ Created at + + + {moment(data.ticket.createdAt).format( + "LLL" + )} + + {data.ticket.client && ( + + for{" "} + + {data.ticket.client.name} + + + )} + +
+ )} + + )} +
+
+
+ {/* Pinned: original ticket message */} + {data.ticket.detail && ( +
+
+ + {data.ticket.createdBy?.name || + data.ticket.email || + "Unknown"} + + + + {moment(data.ticket.createdAt).format("LLL")} + + + Original message + +
+
+ +
+
+ )} +
+
    + {(() => { + const comments = Array.isArray(data?.ticket?.comments) + ? data.ticket.comments + : []; + const files = Array.isArray(filesData?.files) + ? filesData.files + : []; + + const isAttachmentOnlyComment = (text: any) => { + const s = String(text || "").trim(); + if (!s) return false; + return /^(\s*(?:!\[[^\]]*\]\(\/api\/v1\/storage\/ticket-file\/[^)]+\)|\[[^\]]+\]\(\/api\/v1\/storage\/ticket-file\/[^)]+\))\s*(?:\n+|$))+$/m.test( + s + ); + }; + + const items = [ + ...comments + .filter((c: any) => !isAttachmentOnlyComment(c?.text)) + .map((c: any) => ({ + kind: "comment", + id: c.id, + createdAt: c.createdAt, + user: c.user, + userId: c.userId, + replyEmail: c.replyEmail, + text: c.text, + canDelete: + user.isAdmin || + (c.user && c.userId === user.id), + })), + ...files.map((f: any) => ({ + kind: "file", + id: f.id, + createdAt: f.createdAt, + user: f.user, + filename: f.filename, + mime: f.mime, + size: f.size, + url: f.url, + })), + ].sort( + (a: any, b: any) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + ); + + // Group attachments into the nearest comment so message + files appear as one bubble. + const usedFileIds = new Set(); + const fileItems = items.filter((i: any) => i.kind === "file"); + const commentItems = items.filter((i: any) => i.kind === "comment"); + const windowMs = 15_000; + + const filesForComment = (comment: any) => { + const uid = comment.user?.id || comment.userId; + if (!uid) return []; + const t = new Date(comment.createdAt).getTime(); + return fileItems + .filter((f: any) => { + if (usedFileIds.has(f.id)) return false; + const fuid = f.user?.id; + if (!fuid || fuid !== uid) return false; + const ft = new Date(f.createdAt).getTime(); + return Math.abs(ft - t) <= windowMs; + }) + .sort( + (a: any, b: any) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + ); + }; + + const merged = items + .map((item: any) => { + if (item.kind === "comment") { + const attachments = filesForComment(item); + attachments.forEach((f: any) => usedFileIds.add(f.id)); + return { ...item, attachments }; + } + return item; + }) + .filter((item: any) => !(item.kind === "file" && usedFileIds.has(item.id))); + + return merged.map((item: any) => { + // Messenger layout: + // - Support/internal users: external_user=false + // - Clients: external_user=true OR no user (email/unknown) + const isSupport = + Boolean(item.user) && + item.user.external_user !== true; + const isClient = !isSupport; + + // Alignment: + // - Dashboard: client left, support right + // - Portal: client right, support left + const alignClass = isPortal + ? isClient + ? "justify-end" + : "justify-start" + : isSupport + ? "justify-end" + : "justify-start"; + + // Colors: + // - Client: teal + // - Support: grey (both dashboards) + const bubbleClass = isSupport + ? "bg-muted text-foreground" + : "bg-teal-500 text-white"; + + const rawUserName = + (item.user && typeof item.user.name === "string" + ? item.user.name + : "") || ""; + const cleanedUserName = rawUserName.trim(); + const fallbackEmail = + (item.user && + typeof item.user.email === "string" && + item.user.email) || + ""; + const name = + cleanedUserName || + fallbackEmail || + item.replyEmail || + "Unknown"; + + const avatarUrl = item.user?.image || ""; + const avatarFallback = String(name).slice(0, 1); + + return ( +
  • +
    +
    + {alignClass === "justify-start" && ( + + + + {avatarFallback} + + + )} + +
    +
    + + {name} + + + {moment(item.createdAt).format( + "LLL" + )} + +
    + + {item.kind === "comment" ? ( + <> + {item.canDelete && ( + { + deleteComment(item.id); + }} + /> + )} +
    + {Array.isArray(item.attachments) && + item.attachments.length > 0 && ( +
    + {item.attachments.map((att: any) => ( +
    + {typeof att.mime === "string" && + att.mime.startsWith("image/") ? ( + + ) : typeof att.mime === "string" && + att.mime.startsWith("video/") ? ( +
    + + {att.filename && ( +
    + {att.filename} +
    + )} +
    + ) : ( + <> +
    + FILE +
    +
    +
    + {att.filename} +
    +
    + + + + {typeof att.size === + "number" && ( + + {Math.max( + 1, + Math.round( + att.size / + 1024 + ) + )}{" "} + KB + + )} +
    +
    + + )} +
    + ))} +
    + )} + + ) : ( +
    + {typeof item.mime === "string" && + item.mime.startsWith("image/") ? ( +
    + +
    + ) : ( + <> + {typeof item.mime === "string" && + item.mime.startsWith("video/") ? ( +
    + + {item.filename && ( +
    + {item.filename} +
    + )} +
    + ) : ( + <> +
    + FILE +
    +
    +
    + {item.filename} +
    +
    + + + + {typeof item.size === + "number" && ( + + {Math.max( + 1, + Math.round( + item.size / 1024 + ) + )}{" "} + KB + + )} +
    +
    + + )} + + )} +
    + )} +
    + + {alignClass === "justify-end" && ( + + + + {avatarFallback} + + + )} +
    +
    +
  • + ); + }); + })()} +
+
+
+
+
+
+
+
+ + {pendingPreviews.length > 0 && ( +
+ {pendingPreviews.map((p) => { + const isImg = p.type.startsWith("image/"); + const isVid = p.type.startsWith("video/"); + const showName = !isImg; + return ( +
+ {isImg ? ( + {p.name} + ) : isVid ? ( +
+
+ ) : ( +
+ FILE +
+ )} + + {showName && ( +
+
+ {p.name} +
+
+ )} + + +
+ ); + })} +
+ )} +