Skip to content

Commit 795caa3

Browse files
korutxclaude
andcommitted
feat(whatsapp): group-by control for the conversation list
Driver numbers are reused across services, so add a user-selectable "Group by" pivot over the per-phone threads (computed client-side from fields the list API already returns — driver_id, context_service_code, wa_contact_name): - Recent (default): flat, most-recently-active first. - Service: bucketed under the trip/service each thread is tied to. - Driver: bucketed by driver. Grouping is a view only — conversations stay one-per-number, so inbound routing is unambiguous. Refactors the row into ConversationRow reused by all views; en/es strings added. Slice 3 of #852. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAzRnBWEgiTESmwzRNLMzu
1 parent 69e0cce commit 795caa3

5 files changed

Lines changed: 191 additions & 56 deletions

File tree

turbo-repo/apps/app/src/features/whatsapp-inbox/components/conversation-list.tsx

Lines changed: 112 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,38 @@ import type { I18nRecord } from "@/features/i18n/i18n.service.types";
55
import { tr } from "@/features/i18n/tr.service";
66
import type { Conversation } from "../conversation.types";
77
import { conversationName, formatListTime } from "../format";
8+
import { groupConversations, type GroupBy } from "../grouping";
89

910
interface ConversationListProps {
1011
readonly conversations: Conversation[];
1112
readonly isLoading: boolean;
1213
readonly selectedId: string | null;
1314
readonly onSelect: (id: string) => void;
15+
readonly groupBy: GroupBy;
16+
readonly onGroupByChange: (groupBy: GroupBy) => void;
1417
readonly dict: I18nRecord;
1518
readonly locale: string;
1619
}
1720

21+
const GROUP_OPTIONS: readonly GroupBy[] = ["recent", "service", "driver"];
22+
const GROUP_LABEL_KEY: Record<GroupBy, string> = {
23+
recent: "groupByRecent",
24+
service: "groupByService",
25+
driver: "groupByDriver",
26+
};
27+
1828
/**
1929
* Left column: one row per conversation (driver name / phone, last-message preview, time, and an
20-
* unread badge), most-recently-active first. Mirrors the org-switcher list styling.
30+
* unread badge), most-recently-active first. A "group by" control pivots the list flat, by service,
31+
* or by driver — a view over the per-phone threads, computed client-side.
2132
*/
2233
export default function ConversationList({
2334
conversations,
2435
isLoading,
2536
selectedId,
2637
onSelect,
38+
groupBy,
39+
onGroupByChange,
2740
dict,
2841
locale,
2942
}: ConversationListProps) {
@@ -46,70 +59,113 @@ export default function ConversationList({
4659
);
4760
}
4861

62+
const groups = groupConversations(conversations, groupBy, dict);
63+
4964
return (
5065
<aside className={`${shell} self-start`}>
51-
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
66+
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
5267
<h2 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wide">
5368
{tr("listTitle", dict)}
5469
</h2>
70+
<label className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
71+
<span className="sr-only sm:not-sr-only">{tr("groupBy", dict)}</span>
72+
<select
73+
value={groupBy}
74+
onChange={(e) => onGroupByChange(e.target.value as GroupBy)}
75+
className="rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 py-1 pl-2 pr-6 text-xs text-gray-700 dark:text-gray-200 cursor-pointer"
76+
aria-label={tr("groupBy", dict)}
77+
>
78+
{GROUP_OPTIONS.map((option) => (
79+
<option key={option} value={option}>
80+
{tr(GROUP_LABEL_KEY[option], dict)}
81+
</option>
82+
))}
83+
</select>
84+
</label>
5585
</div>
5686
<ul>
57-
{conversations.map((conversation, idx) => {
58-
const isActive = conversation.id === selectedId;
59-
const isLast = idx === conversations.length - 1;
60-
const hasUnread = conversation.unreadCount > 0;
61-
return (
62-
<li key={conversation.id}>
63-
<button
64-
type="button"
65-
onClick={() => onSelect(conversation.id)}
66-
className={`w-full text-left flex items-center gap-3 px-4 h-16 cursor-pointer transition-all duration-300 ${
67-
isActive
68-
? "bg-blue-50/50 dark:bg-blue-900/20"
69-
: "hover:bg-gray-100 dark:hover:bg-gray-700"
70-
} ${isLast ? "" : "border-b border-gray-200 dark:border-gray-700"}`}
71-
>
72-
<HiUserCircle
73-
className={`h-7 w-7 shrink-0 ${
74-
isActive
75-
? "text-blue-500 dark:text-blue-400"
76-
: "text-gray-400 dark:text-gray-500"
77-
}`}
87+
{groups.map((group) => (
88+
<li key={group.key}>
89+
{group.label !== null && (
90+
<div className="px-4 py-1.5 bg-gray-50 dark:bg-gray-900/40 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
91+
{group.label}
92+
</div>
93+
)}
94+
<ul>
95+
{group.items.map((conversation) => (
96+
<ConversationRow
97+
key={conversation.id}
98+
conversation={conversation}
99+
isActive={conversation.id === selectedId}
100+
onSelect={onSelect}
101+
dict={dict}
102+
locale={locale}
78103
/>
79-
<span className="min-w-0 flex-1">
80-
<span className="flex items-center justify-between gap-2">
81-
<span
82-
className={`block text-sm truncate ${
83-
isActive
84-
? "font-semibold text-blue-700 dark:text-blue-300"
85-
: "font-medium text-gray-900 dark:text-white"
86-
}`}
87-
>
88-
{conversationName(conversation)}
89-
</span>
90-
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
91-
{formatListTime(conversation.lastMessageAt, locale)}
92-
</span>
93-
</span>
94-
<span className="flex items-center justify-between gap-2">
95-
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">
96-
{conversation.lastMessagePreview ?? ""}
97-
</span>
98-
{hasUnread && (
99-
<span
100-
className="shrink-0 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-green-500 text-white text-xs font-semibold"
101-
aria-label={tr("unread", dict)}
102-
>
103-
{conversation.unreadCount}
104-
</span>
105-
)}
106-
</span>
107-
</span>
108-
</button>
109-
</li>
110-
);
111-
})}
104+
))}
105+
</ul>
106+
</li>
107+
))}
112108
</ul>
113109
</aside>
114110
);
115111
}
112+
113+
function ConversationRow({
114+
conversation,
115+
isActive,
116+
onSelect,
117+
dict,
118+
locale,
119+
}: {
120+
readonly conversation: Conversation;
121+
readonly isActive: boolean;
122+
readonly onSelect: (id: string) => void;
123+
readonly dict: I18nRecord;
124+
readonly locale: string;
125+
}) {
126+
const hasUnread = conversation.unreadCount > 0;
127+
return (
128+
<button
129+
type="button"
130+
onClick={() => onSelect(conversation.id)}
131+
className={`w-full text-left flex items-center gap-3 px-4 h-16 cursor-pointer transition-all duration-300 border-b border-gray-200 dark:border-gray-700 ${
132+
isActive ? "bg-blue-50/50 dark:bg-blue-900/20" : "hover:bg-gray-100 dark:hover:bg-gray-700"
133+
}`}
134+
>
135+
<HiUserCircle
136+
className={`h-7 w-7 shrink-0 ${
137+
isActive ? "text-blue-500 dark:text-blue-400" : "text-gray-400 dark:text-gray-500"
138+
}`}
139+
/>
140+
<span className="min-w-0 flex-1">
141+
<span className="flex items-center justify-between gap-2">
142+
<span
143+
className={`block text-sm truncate ${
144+
isActive
145+
? "font-semibold text-blue-700 dark:text-blue-300"
146+
: "font-medium text-gray-900 dark:text-white"
147+
}`}
148+
>
149+
{conversationName(conversation)}
150+
</span>
151+
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
152+
{formatListTime(conversation.lastMessageAt, locale)}
153+
</span>
154+
</span>
155+
<span className="flex items-center justify-between gap-2">
156+
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">
157+
{conversation.lastMessagePreview ?? ""}
158+
</span>
159+
{hasUnread && (
160+
<span
161+
className="shrink-0 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-green-500 text-white text-xs font-semibold"
162+
aria-label={tr("unread", dict)}
163+
>
164+
{conversation.unreadCount}
165+
</span>
166+
)}
167+
</span>
168+
</span>
169+
</button>
170+
);
171+
}

turbo-repo/apps/app/src/features/whatsapp-inbox/components/conversations-page-content.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FaWhatsapp } from "react-icons/fa";
55
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
66
import { tr } from "@/features/i18n/tr.service";
77
import { useConversations } from "../use-conversations";
8+
import type { GroupBy } from "../grouping";
89
import ConversationList from "./conversation-list";
910
import MessageThread from "./message-thread";
1011

@@ -21,6 +22,7 @@ interface ConversationsPageContentProps {
2122
export default function ConversationsPageContent({ dict, locale }: ConversationsPageContentProps) {
2223
const { conversations, isLoading, error, refresh } = useConversations();
2324
const [selectedId, setSelectedId] = useState<string | null>(null);
25+
const [groupBy, setGroupBy] = useState<GroupBy>("recent");
2426

2527
const selected = conversations.find((c) => c.id === selectedId) ?? null;
2628

@@ -48,6 +50,8 @@ export default function ConversationsPageContent({ dict, locale }: Conversations
4850
isLoading={isLoading}
4951
selectedId={selectedId}
5052
onSelect={setSelectedId}
53+
groupBy={groupBy}
54+
onGroupByChange={setGroupBy}
5155
dict={dict}
5256
locale={locale}
5357
/>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
2+
import { tr } from "@/features/i18n/tr.service";
3+
import type { Conversation } from "./conversation.types";
4+
5+
/**
6+
* How the conversation list is pivoted. Driver phone numbers are reused across services over time,
7+
* so the same thread can be viewed flat (most recent first), bucketed by the trip/service it's
8+
* currently tied to, or bucketed by driver. Grouping is a VIEW over the per-phone threads — we never
9+
* fork a conversation per trip, so an inbound reply always lands on one place.
10+
*/
11+
export type GroupBy = "recent" | "service" | "driver";
12+
13+
export interface ConversationGroup {
14+
readonly key: string;
15+
/** Section header text; {@code null} renders a flat list with no header (the "recent" view). */
16+
readonly label: string | null;
17+
readonly items: Conversation[];
18+
}
19+
20+
/** Most-recently-active first; missing timestamps sort last. ISO strings compare chronologically. */
21+
function byRecent(a: Conversation | undefined, b: Conversation | undefined): number {
22+
return (b?.lastMessageAt ?? "").localeCompare(a?.lastMessageAt ?? "");
23+
}
24+
25+
export function groupConversations(
26+
conversations: Conversation[],
27+
groupBy: GroupBy,
28+
dict: I18nRecord,
29+
): ConversationGroup[] {
30+
const sorted = [...conversations].sort(byRecent);
31+
32+
if (groupBy === "recent") {
33+
return [{ key: "all", label: null, items: sorted }];
34+
}
35+
36+
const keyOf = (c: Conversation): string =>
37+
groupBy === "service"
38+
? (c.contextServiceCode ?? "")
39+
: (c.driverId ?? c.waContactName ?? c.phoneE164);
40+
41+
const buckets = new Map<string, Conversation[]>();
42+
for (const conversation of sorted) {
43+
const key = keyOf(conversation);
44+
const bucket = buckets.get(key);
45+
if (bucket) {
46+
bucket.push(conversation);
47+
} else {
48+
buckets.set(key, [conversation]);
49+
}
50+
}
51+
52+
const groups: ConversationGroup[] = [];
53+
for (const [key, items] of buckets) {
54+
const label =
55+
groupBy === "service"
56+
? key || tr("noService", dict)
57+
: (items[0]?.waContactName ?? items[0]?.phoneE164 ?? tr("noDriver", dict));
58+
groups.push({ key: key || "__none__", label, items });
59+
}
60+
61+
// Order groups by their most-recent conversation (items are already recency-sorted).
62+
return groups.sort((g1, g2) => byRecent(g1.items[0], g2.items[0]));
63+
}

turbo-repo/apps/app/src/lang/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4002,6 +4002,12 @@
40024002
"title": "WhatsApp Conversations",
40034003
"description": "Driver replies and message delivery status for your organization.",
40044004
"listTitle": "Conversations",
4005+
"groupBy": "Group by",
4006+
"groupByRecent": "Recent",
4007+
"groupByService": "Service",
4008+
"groupByDriver": "Driver",
4009+
"noService": "No service",
4010+
"noDriver": "Unknown driver",
40054011
"loading": "Loading conversations…",
40064012
"empty": "No conversations yet.",
40074013
"noSelection": "Select a conversation to view the thread.",

turbo-repo/apps/app/src/lang/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4002,6 +4002,12 @@
40024002
"title": "Conversaciones de WhatsApp",
40034003
"description": "Respuestas de conductores y estado de entrega de los mensajes de tu organización.",
40044004
"listTitle": "Conversaciones",
4005+
"groupBy": "Agrupar por",
4006+
"groupByRecent": "Recientes",
4007+
"groupByService": "Servicio",
4008+
"groupByDriver": "Conductor",
4009+
"noService": "Sin servicio",
4010+
"noDriver": "Conductor desconocido",
40054011
"loading": "Cargando conversaciones…",
40064012
"empty": "Aún no hay conversaciones.",
40074013
"noSelection": "Selecciona una conversación para ver los mensajes.",

0 commit comments

Comments
 (0)