Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bcd1a2a2d | |||
| efd31d9293 | |||
| a2a7e71f59 |
@@ -14,6 +14,7 @@ import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api
|
|||||||
import { MailReader, NewMailModal } from "./mail";
|
import { MailReader, NewMailModal } from "./mail";
|
||||||
|
|
||||||
const KIND_LABEL: Record<string, string> = {
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
MAIL: "Mail",
|
||||||
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
|
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
|
||||||
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
|
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
|
||||||
};
|
};
|
||||||
@@ -103,7 +104,7 @@ function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; on
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
|
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
|
||||||
<Icon name={it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
|
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
@@ -122,8 +123,9 @@ function Detail({ it, onError, onDone, onSnooze, onArchive }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Item actions bar (works for every item, threaded or not) */}
|
{/* Item actions bar — only for real inbox work-items. Mail isn't an inbox item
|
||||||
{it.state === "OPEN" && (
|
(no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */}
|
||||||
|
{it.state === "OPEN" && it.kind !== "MAIL" && (
|
||||||
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
|
||||||
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
|
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
|
||||||
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
|
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
|
||||||
@@ -133,8 +135,10 @@ function Detail({ it, onError, onDone, onSnooze, onArchive }: {
|
|||||||
|
|
||||||
{it.threadId ? (
|
{it.threadId ? (
|
||||||
// A message/mail item → open the conversation to read + reply.
|
// A message/mail item → open the conversation to read + reply.
|
||||||
|
// key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the
|
||||||
|
// variables — so switching items must remount MailReader to load the new thread's history.
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<MailReader threadId={it.threadId} subject={it.title} onError={onError} />
|
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// A non-threaded item (e.g. a system alert) → show its detail.
|
// A non-threaded item (e.g. a system alert) → show its detail.
|
||||||
|
|||||||
@@ -216,13 +216,16 @@ export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean
|
|||||||
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
|
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
|
||||||
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
|
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
|
||||||
|
|
||||||
<Field label="Attachments">
|
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
|
||||||
|
Attach button's click via label→input association and open the picker erratically. */}
|
||||||
|
<div className="ds-field">
|
||||||
|
<span className="ds-field-lbl">Attachments</span>
|
||||||
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
|
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||||
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
|
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
|
||||||
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
|
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-2
@@ -8,9 +8,10 @@
|
|||||||
// query crm.inbox.list { state? } -> InboxItem[]
|
// query crm.inbox.list { state? } -> InboxItem[]
|
||||||
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
|
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||||
import { isShellConfigured } from "./appshell";
|
import { isShellConfigured } from "./appshell";
|
||||||
|
import type { MailThread } from "./mail-api";
|
||||||
|
|
||||||
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
|
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
|
||||||
export interface UiInboxItem {
|
export interface UiInboxItem {
|
||||||
@@ -34,11 +35,48 @@ export function useInboxData(state?: InboxState): InboxData {
|
|||||||
function useLiveInbox(state?: InboxState): InboxData {
|
function useLiveInbox(state?: InboxState): InboxData {
|
||||||
const { sdk } = useAppShell();
|
const { sdk } = useAppShell();
|
||||||
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
|
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
|
||||||
|
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
|
||||||
|
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
|
||||||
|
const showMail = !state || state === "OPEN";
|
||||||
|
const mq = useQuery<MailThread[]>("crm.mail.list", {});
|
||||||
|
|
||||||
|
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
|
||||||
|
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
|
||||||
|
const refetchInbox = q.refetch;
|
||||||
|
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
|
||||||
|
|
||||||
|
const items = useMemo<UiInboxItem[]>(() => {
|
||||||
|
const inboxItems = q.data ?? [];
|
||||||
|
const mailItems: UiInboxItem[] = showMail
|
||||||
|
? (mq.data ?? []).map((t) => ({
|
||||||
|
id: `mail:${t.threadId}`,
|
||||||
|
kind: "MAIL",
|
||||||
|
state: "OPEN" as InboxState,
|
||||||
|
title: t.subject || "(no subject)",
|
||||||
|
...(t.lastMessage ? { summary: t.lastMessage } : {}),
|
||||||
|
priority: t.unread > 0 ? "HIGH" : "LOW",
|
||||||
|
threadId: t.threadId,
|
||||||
|
createdAt: t.lastAt ?? "",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
// Newest first; mail and inbox items interleave by time.
|
||||||
|
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
||||||
|
}, [q.data, mq.data, showMail]);
|
||||||
|
|
||||||
const transition = useCallback(async (id: string, next: InboxState) => {
|
const transition = useCallback(async (id: string, next: InboxState) => {
|
||||||
await sdk.command("crm.inbox.transition", { id, state: next });
|
await sdk.command("crm.inbox.transition", { id, state: next });
|
||||||
q.refetch();
|
q.refetch();
|
||||||
}, [sdk, q]);
|
}, [sdk, q]);
|
||||||
return { live: true, loading: q.loading, error: q.error?.message ?? null, items: q.data ?? [], transition, refetch: q.refetch };
|
|
||||||
|
return {
|
||||||
|
live: true,
|
||||||
|
loading: q.loading || (showMail && mq.loading),
|
||||||
|
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
|
||||||
|
error: q.error?.message ?? null,
|
||||||
|
items,
|
||||||
|
transition,
|
||||||
|
refetch: () => { q.refetch(); mq.refetch(); },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_ITEMS: UiInboxItem[] = [
|
const MOCK_ITEMS: UiInboxItem[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user