mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(inbox): filter conversations by contact tags and company
Adds contact-based filters to the Inbox conversation list (issue #272): - Multi-select tag filter with OR logic (a conversation matches if its contact has any selected tag), consistent with Broadcast audiences. - Company filter (exact match) over companies present in the inbox. - Filters stack with the existing status filter and search; active selections show as removable chips with a clear-all control. Tags are hydrated via a shared CONVERSATION_SELECT embed and normalized onto contact.tags, so the realtime hydrate path keeps new conversations matching an active filter. Filtering logic lives in a pure, unit-tested matchesContactFilters helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
65b677181e
commit
f8aaac131b
@ -3,6 +3,10 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import {
|
||||
CONVERSATION_SELECT,
|
||||
normalizeConversation,
|
||||
} from "@/lib/inbox/conversations";
|
||||
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { ConversationList } from "@/components/inbox/conversation-list";
|
||||
@ -118,7 +122,7 @@ export default function InboxPage() {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("conversations")
|
||||
.select("*, contact:contacts(*)")
|
||||
.select(CONVERSATION_SELECT)
|
||||
.eq("id", convId)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
@ -133,7 +137,7 @@ export default function InboxPage() {
|
||||
return;
|
||||
}
|
||||
if (!data) return;
|
||||
const fetched = data as Conversation;
|
||||
const fetched = normalizeConversation(data);
|
||||
setConversations((prev) => {
|
||||
const existing = prev.find((c) => c.id === fetched.id);
|
||||
if (existing) {
|
||||
|
||||
@ -2,18 +2,23 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import {
|
||||
CONVERSATION_SELECT,
|
||||
matchesContactFilters,
|
||||
normalizeConversations,
|
||||
} from "@/lib/inbox/conversations";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Conversation, ConversationStatus } from "@/types";
|
||||
import { Search, ChevronDown } from "lucide-react";
|
||||
import type { Conversation, ConversationStatus, Tag } from "@/types";
|
||||
import { Search, ChevronDown, X } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface ConversationListProps {
|
||||
@ -56,6 +61,12 @@ export function ConversationList({
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<InboxFilter>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Contact-based filters (issue #272). Tags use OR logic (a conversation
|
||||
// matches if its contact carries any selected tag), consistent with
|
||||
// Broadcast audience filtering. Company is an exact match on the field.
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||
|
||||
// Keep the latest callback in a ref so the fetch effect below can
|
||||
// have a stable, empty-dep identity. Previously the fetch useCallback
|
||||
@ -81,7 +92,7 @@ export function ConversationList({
|
||||
(async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("conversations")
|
||||
.select("*, contact:contacts(*)")
|
||||
.select(CONVERSATION_SELECT)
|
||||
.order("last_message_at", { ascending: false });
|
||||
|
||||
if (cancelled) return;
|
||||
@ -98,7 +109,7 @@ export function ConversationList({
|
||||
return;
|
||||
}
|
||||
|
||||
onConversationsLoadedRef.current(data ?? []);
|
||||
onConversationsLoadedRef.current(normalizeConversations(data ?? []));
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
@ -110,6 +121,38 @@ export function ConversationList({
|
||||
// up on any events sent while the WS was disconnected or throttled.
|
||||
}, [resyncToken]);
|
||||
|
||||
// Tag definitions for the filter picker — loaded once so labels/colours
|
||||
// stay stable regardless of which conversations happen to be loaded.
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { data } = await supabase.from("tags").select("*").order("name");
|
||||
if (!cancelled && data) setTags(data as Tag[]);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Company options are derived from the loaded conversations — there's no
|
||||
// separate companies table, and only companies with a live conversation
|
||||
// are worth offering as an inbox filter.
|
||||
const companies = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const c of conversations) {
|
||||
const co = c.contact?.company?.trim();
|
||||
if (co) set.add(co);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [conversations]);
|
||||
|
||||
const tagsById = useMemo(() => {
|
||||
const m = new Map<string, Tag>();
|
||||
for (const t of tags) m.set(t.id, t);
|
||||
return m;
|
||||
}, [tags]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = conversations;
|
||||
|
||||
@ -119,6 +162,16 @@ export function ConversationList({
|
||||
result = result.filter((c) => c.status === filter);
|
||||
}
|
||||
|
||||
// Contact-based filters (tags via OR logic, exact company match).
|
||||
if (selectedTagIds.length > 0 || selectedCompany !== null) {
|
||||
result = result.filter((c) =>
|
||||
matchesContactFilters(c, {
|
||||
tagIds: selectedTagIds,
|
||||
company: selectedCompany,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
@ -130,7 +183,20 @@ export function ConversationList({
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [conversations, filter, search]);
|
||||
}, [conversations, filter, search, selectedTagIds, selectedCompany]);
|
||||
|
||||
const toggleTag = useCallback((id: string) => {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearContactFilters = useCallback(() => {
|
||||
setSelectedTagIds([]);
|
||||
setSelectedCompany(null);
|
||||
}, []);
|
||||
|
||||
const hasContactFilters = selectedTagIds.length > 0 || selectedCompany !== null;
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -165,6 +231,7 @@ export function ConversationList({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
|
||||
{activeFilter?.label ?? "All"}
|
||||
@ -190,6 +257,132 @@ export function ConversationList({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
|
||||
selectedTagIds.length > 0
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Tags
|
||||
{selectedTagIds.length > 0 && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
|
||||
{selectedTagIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-64 w-56 border-border bg-popover"
|
||||
>
|
||||
{tags.map((t) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={t.id}
|
||||
checked={selectedTagIds.includes(t.id)}
|
||||
onCheckedChange={() => toggleTag(t.id)}
|
||||
className="text-sm text-popover-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: t.color }}
|
||||
/>
|
||||
<span className="truncate">{t.name}</span>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{companies.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex max-w-40 items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
|
||||
selectedCompany
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selectedCompany ?? "Company"}</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-64 w-56 border-border bg-popover"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedCompany(null)}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
selectedCompany === null
|
||||
? "text-primary"
|
||||
: "text-popover-foreground"
|
||||
)}
|
||||
>
|
||||
All companies
|
||||
</DropdownMenuItem>
|
||||
{companies.map((co) => (
|
||||
<DropdownMenuItem
|
||||
key={co}
|
||||
onClick={() => setSelectedCompany(co)}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
selectedCompany === co
|
||||
? "text-primary"
|
||||
: "text-popover-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{co}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasContactFilters && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{selectedTagIds.map((id) => {
|
||||
const tag = tagsById.get(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggleTag(id)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag?.color ?? "var(--muted-foreground)" }}
|
||||
/>
|
||||
<span className="max-w-24 truncate">{tag?.name ?? "Tag"}</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedCompany && (
|
||||
<button
|
||||
onClick={() => setSelectedCompany(null)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
|
||||
>
|
||||
<span className="max-w-24 truncate">{selectedCompany}</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={clearContactFilters}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversation Items.
|
||||
|
||||
145
src/lib/inbox/conversations.test.ts
Normal file
145
src/lib/inbox/conversations.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
matchesContactFilters,
|
||||
normalizeConversation,
|
||||
} from "./conversations";
|
||||
import type { Conversation } from "@/types";
|
||||
|
||||
function makeConversation(
|
||||
contact: Partial<Conversation["contact"]> | null,
|
||||
): Conversation {
|
||||
return {
|
||||
id: "c1",
|
||||
user_id: "u1",
|
||||
contact_id: "ct1",
|
||||
status: "open",
|
||||
unread_count: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
contact: contact
|
||||
? {
|
||||
id: "ct1",
|
||||
user_id: "u1",
|
||||
account_id: "a1",
|
||||
phone: "123",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...contact,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const tag = (id: string, name = id) => ({
|
||||
id,
|
||||
user_id: "u1",
|
||||
name,
|
||||
color: "#fff",
|
||||
created_at: "",
|
||||
});
|
||||
|
||||
describe("matchesContactFilters", () => {
|
||||
it("matches everything when no filters are set", () => {
|
||||
const conv = makeConversation({ company: "Acme", tags: [tag("t1")] });
|
||||
expect(matchesContactFilters(conv, { tagIds: [], company: null })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(makeConversation(null)).toBeDefined();
|
||||
expect(
|
||||
matchesContactFilters(makeConversation(null), {
|
||||
tagIds: [],
|
||||
company: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses OR logic across tags", () => {
|
||||
const conv = makeConversation({ tags: [tag("t1"), tag("t2")] });
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: ["t2", "t9"], company: null }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: ["t9"], company: null }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes conversations whose contact has no tags when a tag filter is active", () => {
|
||||
const conv = makeConversation({ tags: [] });
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: ["t1"], company: null }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesContactFilters(makeConversation(null), {
|
||||
tagIds: ["t1"],
|
||||
company: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches company exactly, trimming whitespace", () => {
|
||||
const conv = makeConversation({ company: " Acme " });
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: [], company: "Acme" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: [], company: "Other" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires both tag and company to match when both are set (AND across facets)", () => {
|
||||
const conv = makeConversation({ company: "Acme", tags: [tag("t1")] });
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: ["t1"], company: "Acme" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: ["t1"], company: "Other" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesContactFilters(conv, { tagIds: ["tX"], company: "Acme" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeConversation", () => {
|
||||
it("flattens embedded contact_tags into contact.tags", () => {
|
||||
const raw = {
|
||||
id: "c1",
|
||||
user_id: "u1",
|
||||
contact_id: "ct1",
|
||||
status: "open" as const,
|
||||
unread_count: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
contact: {
|
||||
id: "ct1",
|
||||
user_id: "u1",
|
||||
account_id: "a1",
|
||||
phone: "123",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
contact_tags: [{ tags: tag("t1", "VIP") }, { tags: null }],
|
||||
},
|
||||
};
|
||||
const normalized = normalizeConversation(raw);
|
||||
expect(normalized.contact?.tags).toEqual([tag("t1", "VIP")]);
|
||||
// The raw join key is dropped from the flattened contact.
|
||||
expect(
|
||||
(normalized.contact as unknown as Record<string, unknown>).contact_tags,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through a conversation with no contact", () => {
|
||||
const raw = {
|
||||
id: "c1",
|
||||
user_id: "u1",
|
||||
contact_id: "ct1",
|
||||
status: "open" as const,
|
||||
unread_count: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
contact: null,
|
||||
};
|
||||
// A contactless row passes through untouched (consumers use `?.`).
|
||||
expect(normalizeConversation(raw).contact).toBeNull();
|
||||
});
|
||||
});
|
||||
71
src/lib/inbox/conversations.ts
Normal file
71
src/lib/inbox/conversations.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { Conversation, Contact, Tag } from "@/types";
|
||||
|
||||
/**
|
||||
* Conversation select that embeds the contact plus its tags, so the Inbox
|
||||
* can filter conversations by contact tag without a second round-trip.
|
||||
* `contact_tags(tags(*))` returns the join rows; {@link normalizeConversation}
|
||||
* flattens them onto `contact.tags`.
|
||||
*/
|
||||
export const CONVERSATION_SELECT =
|
||||
"*, contact:contacts(*, contact_tags(tags(*)))";
|
||||
|
||||
/** Raw shape returned by {@link CONVERSATION_SELECT} before flattening. */
|
||||
type RawContact = Contact & { contact_tags?: { tags: Tag | null }[] };
|
||||
type RawConversation = Omit<Conversation, "contact"> & {
|
||||
contact?: RawContact | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flatten the embedded `contact_tags(tags(*))` join into `contact.tags`.
|
||||
* Safe to call on rows fetched with {@link CONVERSATION_SELECT}; a row with
|
||||
* no contact (e.g. a freshly-inserted conversation) passes through untouched.
|
||||
*/
|
||||
export function normalizeConversation(raw: RawConversation): Conversation {
|
||||
const rawContact = raw.contact;
|
||||
if (!rawContact) return raw as Conversation;
|
||||
|
||||
const { contact_tags, ...contact } = rawContact;
|
||||
return {
|
||||
...raw,
|
||||
contact: {
|
||||
...contact,
|
||||
tags: (contact_tags ?? [])
|
||||
.map((ct) => ct.tags)
|
||||
.filter((t): t is Tag => t != null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConversations(
|
||||
rows: RawConversation[],
|
||||
): Conversation[] {
|
||||
return rows.map(normalizeConversation);
|
||||
}
|
||||
|
||||
export interface ContactFilters {
|
||||
/** Tag ids; a conversation matches if its contact has ANY of them (OR). */
|
||||
tagIds: string[];
|
||||
/** Exact company match, or null for no company filter. */
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a conversation passes the contact-based Inbox filters (issue #272).
|
||||
* Empty `tagIds` and null `company` are no-ops, so the default (no filters)
|
||||
* always matches. Tags use OR logic, consistent with Broadcast audiences.
|
||||
*/
|
||||
export function matchesContactFilters(
|
||||
conversation: Conversation,
|
||||
{ tagIds, company }: ContactFilters,
|
||||
): boolean {
|
||||
if (tagIds.length > 0) {
|
||||
const contactTagIds = conversation.contact?.tags ?? [];
|
||||
if (!contactTagIds.some((t) => tagIds.includes(t.id))) return false;
|
||||
}
|
||||
|
||||
if (company !== null && conversation.contact?.company?.trim() !== company) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -100,6 +100,9 @@ export interface Contact {
|
||||
avatar_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Hydrated by queries that embed `contact_tags(tags(*))` (e.g. the
|
||||
* Inbox conversation list, for tag filtering). Absent otherwise. */
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user