@@ -5,25 +5,38 @@ import type { I18nRecord } from "@/features/i18n/i18n.service.types";
55import { tr } from "@/features/i18n/tr.service" ;
66import type { Conversation } from "../conversation.types" ;
77import { conversationName , formatListTime } from "../format" ;
8+ import { groupConversations , type GroupBy } from "../grouping" ;
89
910interface 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 */
2233export 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+ }
0 commit comments