diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index f78702e..de8381a 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -437,7 +437,7 @@ .dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); } .dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; } .dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; } - .dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; } + .dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; flex: 1 1 auto; min-height: 0; } .dash-root .ds-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border); } /* ---- Toasts ---- */ diff --git a/src/components/dashboard/inbox.tsx b/src/components/dashboard/inbox.tsx index 08dbb3d..56b0e15 100644 --- a/src/components/dashboard/inbox.tsx +++ b/src/components/dashboard/inbox.tsx @@ -1,16 +1,17 @@ "use client"; // ============================================================ -// Inbox — a personalized work/awareness feed IIOS projects from -// events (mentions, needs-reply, support updates, …), surfaced via -// the be-crm data door (crm.inbox.*). List + filter by state, and -// mark items done / snoozed / archived. Items are created by IIOS's -// projector, never here. Mock when the Shell isn't configured. +// Inbox — the ONE unified communication surface. It lists everything +// IIOS surfaces for you (mentions, needs-reply, system alerts, support +// updates, …) AND the mail behind them: click an item tied to a thread +// and its conversation opens on the right to read + reply. Compose new +// mail from here too. Items come from crm.inbox.*; threads from crm.mail.*. // ============================================================ -import { useState } from "react"; -import { Btn, Icon, PageHead, Pill } from "./ui"; +import { useEffect, useState } from "react"; +import { Btn, Icon, PageHead, Pill, useToast } from "./ui"; import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api"; +import { MailReader, NewMailModal } from "./mail"; const KIND_LABEL: Record = { MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval", @@ -23,10 +24,22 @@ const FILTERS: { value: InboxState; label: string }[] = [ export function Inbox() { const [filter, setFilter] = useState("OPEN"); const inbox = useInboxData(filter); + const toast = useToast(); + const [selectedId, setSelectedId] = useState(null); + const [newOpen, setNewOpen] = useState(false); + + useEffect(() => { + if ((!selectedId || !inbox.items.some((i) => i.id === selectedId)) && inbox.items[0]) setSelectedId(inbox.items[0].id); + }, [inbox.items, selectedId]); + + const selected = inbox.items.find((i) => i.id === selectedId) ?? null; return (
- + setNewOpen(true)}>New mail} + /> {!inbox.live && (
Demo mode — running on mock data. It goes live once the Shell + be-crm are connected. @@ -39,47 +52,97 @@ export function Inbox() { ))}
-
- {inbox.loading &&
Loading…
} - {!inbox.loading && inbox.items.length === 0 && ( -
Nothing here — you're all caught up 🎉
- )} - {inbox.items.map((it) => ( - inbox.transition(it.id, "DONE")} - onSnooze={() => inbox.transition(it.id, "SNOOZED")} - onArchive={() => inbox.transition(it.id, "ARCHIVED")} - /> - ))} +
+ {/* Left — the unified item list */} + + + {/* Right — read the mail behind the item, or the item detail */} +
+ {selected ? ( + toast.push({ tone: "error", title: "Failed", desc: m })} + onDone={() => inbox.transition(selected.id, "DONE")} + onSnooze={() => inbox.transition(selected.id, "SNOOZED")} + onArchive={() => inbox.transition(selected.id, "ARCHIVED")} + /> + ) : ( +
+

Select an item to read

+
+ )} +
+ + setNewOpen(false)} + onSent={() => { setNewOpen(false); inbox.refetch(); toast.push({ tone: "success", title: "Sent" }); }} + onError={(m) => toast.push({ tone: "error", title: "Couldn't send", desc: m })} + />
); } -function InboxRow({ it, onDone, onSnooze, onArchive }: { it: UiInboxItem; onDone: () => void; onSnooze: () => void; onArchive: () => void }) { +function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; onClick: () => void }) { const isMention = it.kind === "MENTION"; return ( -
- - + + ); +} + +function Detail({ it, onError, onDone, onSnooze, onArchive }: { + it: UiInboxItem; onError: (m: string) => void; onDone: () => void; onSnooze: () => void; onArchive: () => void; +}) { + return ( + <> + {/* Item actions bar (works for every item, threaded or not) */} + {it.state === "OPEN" && ( +
Snooze Done Archive
- ) : ( - {it.state.toLowerCase()} )} -
+ + {it.threadId ? ( + // A message/mail item → open the conversation to read + reply. +
+ +
+ ) : ( + // A non-threaded item (e.g. a system alert) → show its detail. +
+
{it.title}
+ {it.summary &&
{it.summary}
} +
+ )} + ); } diff --git a/src/components/dashboard/mail.tsx b/src/components/dashboard/mail.tsx new file mode 100644 index 0000000..49b7c19 --- /dev/null +++ b/src/components/dashboard/mail.tsx @@ -0,0 +1,228 @@ +"use client"; + +// ============================================================ +// Mail components used INSIDE the Inbox (not a separate tab). +// The Inbox is the one unified surface — mentions, system messages +// and mail all live there. These render the mail body + reply, and +// compose a new message. HTML bodies render in a sandboxed iframe. +// ============================================================ + +import { type CSSProperties, useEffect, useRef, useState } from "react"; +import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui"; +import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api"; +import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api"; + +const timeOf = (iso?: string) => { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +}; +const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)"; +const inputStyle: CSSProperties = { + width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)", + background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", +}; + +function fmtBytes(n: number): string { + if (!n) return ""; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */ +function MailAttachmentView({ att }: { att: MailAttachment }) { + const getUrl = useDownloadUrl(); + const [url, setUrl] = useState(null); + useEffect(() => { + let alive = true; + getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {}); + return () => { alive = false; }; + }, [att.contentRef, att.mimeType, getUrl]); + + const label = att.filename || "Attachment"; + if (isImage(att.mimeType)) { + return url + ? {label} + :
Loading image…
; + } + return ( + + + {label} + {att.sizeBytes > 0 && {fmtBytes(att.sizeBytes)}} + + ); +} + +/** A small staged-file chip shown in a composer before send, with a remove button. */ +function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) { + return ( +
+ + {file.filename} + {fmtBytes(file.sizeBytes)} + +
+ ); +} + +/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */ +export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) { + const t = useMailThread(threadId); + const upload = useUploadAttachment(); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [staged, setStaged] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + async function onPickFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setUploading(true); + try { setStaged(await upload(file)); } + catch (err) { onError((err as Error).message); } + finally { setUploading(false); } + } + + async function reply() { + const text = draft.trim(); + if ((!text && !staged) || sending) return; + const att = staged ?? undefined; + setDraft(""); setStaged(null); setSending(true); + try { await t.reply(text, att); } + catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); } + finally { setSending(false); } + } + + return ( +
+
+
{subject || "(no subject)"}
+
+
+ {t.loading && t.messages.length === 0 &&
Loading…
} + {!t.loading && t.messages.length === 0 &&
No messages.
} + {t.messages.map((m) => ( +
+
+ {m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""} + {timeOf(m.occurredAt)} +
+ {m.html + ?