11import React from 'react' ;
22import { Linking , Modal , ScrollView , StyleSheet , TouchableOpacity , View } from 'react-native' ;
33import { Button , Text , TextInput } from 'react-native-paper' ;
4- import type { GroupMemberSummaryDto , SimplifySuggestionDto } from '@fairshare/shared-types' ;
4+ import type { ActivityDto , GroupMemberSummaryDto , SimplifySuggestionDto } from '@fairshare/shared-types' ;
55import LottieView from 'lottie-react-native' ;
66import { settlementService } from '../services/settlement.service' ;
77import { groupService } from '../services/group.service' ;
88import { useToastStore } from '../store/toastStore' ;
99import { spacing } from '../theme/spacing' ;
1010
11+ const suggestionKey = ( fromUserId : string , toUserId : string , amountCents : string ) => `${ fromUserId } -${ toUserId } -${ amountCents } ` ;
12+
13+ const relativeTime = ( iso : string ) : string => {
14+ const now = Date . now ( ) ;
15+ const timestamp = new Date ( iso ) . getTime ( ) ;
16+ const diffSec = Math . max ( 1 , Math . floor ( ( now - timestamp ) / 1000 ) ) ;
17+
18+ if ( diffSec < 60 ) return `${ diffSec } s ago` ;
19+ const diffMin = Math . floor ( diffSec / 60 ) ;
20+ if ( diffMin < 60 ) return `${ diffMin } m ago` ;
21+ const diffHour = Math . floor ( diffMin / 60 ) ;
22+ if ( diffHour < 24 ) return `${ diffHour } h ago` ;
23+ const diffDay = Math . floor ( diffHour / 24 ) ;
24+ return `${ diffDay } d ago` ;
25+ } ;
26+
1127export function SettleUpScreen ( { route } : { route : { params : { groupId : string } } } ) {
1228 const [ suggestions , setSuggestions ] = React . useState < SimplifySuggestionDto [ ] > ( [ ] ) ;
1329 const [ members , setMembers ] = React . useState < GroupMemberSummaryDto [ ] > ( [ ] ) ;
30+ const [ reminderEvents , setReminderEvents ] = React . useState < ActivityDto [ ] > ( [ ] ) ;
1431 const [ successOpen , setSuccessOpen ] = React . useState ( false ) ;
1532 const [ upiReceiver , setUpiReceiver ] = React . useState ( '' ) ;
1633 const [ upiName , setUpiName ] = React . useState ( 'FairShare Member' ) ;
1734 const [ selectedIndex , setSelectedIndex ] = React . useState < number | null > ( null ) ;
18- const [ remindingIndex , setRemindingIndex ] = React . useState < number | null > ( null ) ;
35+ const [ remindingKey , setRemindingKey ] = React . useState < string | null > ( null ) ;
1936 const [ query , setQuery ] = React . useState ( '' ) ;
2037 const [ fromUserId , setFromUserId ] = React . useState ( '' ) ;
2138 const [ toUserId , setToUserId ] = React . useState ( '' ) ;
@@ -24,12 +41,14 @@ export function SettleUpScreen({ route }: { route: { params: { groupId: string }
2441 React . useEffect ( ( ) => {
2542 const load = async ( ) => {
2643 try {
27- const [ nextSuggestions , nextMembers ] = await Promise . all ( [
44+ const [ nextSuggestions , nextMembers , activityData ] = await Promise . all ( [
2845 groupService . simplify ( route . params . groupId ) ,
2946 groupService . members ( route . params . groupId ) ,
47+ groupService . activity ( route . params . groupId , 0 , 20 ) ,
3048 ] ) ;
3149 setSuggestions ( nextSuggestions ) ;
3250 setMembers ( nextMembers ) ;
51+ setReminderEvents ( activityData . items . filter ( ( event ) => event . type === 'settlement_reminder' ) ) ;
3352 } catch {
3453 toast ( 'Failed to load suggestions' ) ;
3554 }
@@ -60,6 +79,35 @@ export function SettleUpScreen({ route }: { route: { params: { groupId: string }
6079 } ) ;
6180 } , [ fromUserId , query , suggestions , toUserId , members ] ) ;
6281
82+ const latestReminderBySuggestion = React . useMemo ( ( ) => {
83+ const entries = new Map < string , ActivityDto > ( ) ;
84+
85+ reminderEvents . forEach ( ( event ) => {
86+ const payerId = typeof event . metadata ?. payerId === 'string' ? event . metadata . payerId : null ;
87+ const receiverId = typeof event . metadata ?. receiverId === 'string' ? event . metadata . receiverId : null ;
88+ const amountCents = typeof event . metadata ?. amountCents === 'string' ? event . metadata . amountCents : null ;
89+ if ( ! payerId || ! receiverId || ! amountCents ) {
90+ return ;
91+ }
92+
93+ const key = suggestionKey ( payerId , receiverId , amountCents ) ;
94+ const existing = entries . get ( key ) ;
95+ if ( ! existing || new Date ( event . createdAt ) . getTime ( ) > new Date ( existing . createdAt ) . getTime ( ) ) {
96+ entries . set ( key , event ) ;
97+ }
98+ } ) ;
99+
100+ return entries ;
101+ } , [ reminderEvents ] ) ;
102+
103+ const recentReminders = React . useMemo (
104+ ( ) =>
105+ [ ...reminderEvents ]
106+ . sort ( ( a , b ) => new Date ( b . createdAt ) . getTime ( ) - new Date ( a . createdAt ) . getTime ( ) )
107+ . slice ( 0 , 4 ) ,
108+ [ reminderEvents ] ,
109+ ) ;
110+
63111 const resetFilters = ( ) => {
64112 setQuery ( '' ) ;
65113 setFromUserId ( '' ) ;
@@ -129,93 +177,126 @@ export function SettleUpScreen({ route }: { route: { params: { groupId: string }
129177 </ View >
130178 </ View >
131179
180+ { recentReminders . length > 0 ? (
181+ < View style = { styles . historyCard } >
182+ < Text style = { styles . historyTitle } > Recent reminders</ Text >
183+ { recentReminders . map ( ( event ) => {
184+ const payerId = typeof event . metadata ?. payerId === 'string' ? event . metadata . payerId : '' ;
185+ const receiverId = typeof event . metadata ?. receiverId === 'string' ? event . metadata . receiverId : '' ;
186+ const amountCents = typeof event . metadata ?. amountCents === 'string' ? event . metadata . amountCents : '0' ;
187+
188+ return (
189+ < View key = { event . id } style = { styles . historyItem } >
190+ < Text style = { styles . historyText } >
191+ { labelForUser ( event . actorUserId ) } reminded { labelForUser ( payerId ) } to pay { labelForUser ( receiverId ) }
192+ </ Text >
193+ < Text style = { styles . historyMeta } >
194+ ${ ( Number ( amountCents ) / 100 ) . toFixed ( 2 ) } { relativeTime ( event . createdAt ) }
195+ </ Text >
196+ </ View >
197+ ) ;
198+ } ) }
199+ </ View >
200+ ) : null }
201+
132202 { filteredSuggestions . length === 0 ? (
133203 < View style = { styles . emptyCard } >
134204 < Text style = { styles . title } > { suggestions . length === 0 ? 'All clear' : 'No matching settlements' } </ Text >
135205 < Text style = { styles . amount } > { suggestions . length === 0 ? 'No suggested settlements right now.' : 'Try a different payer, receiver, or search query.' } </ Text >
136206 </ View >
137207 ) : null }
138208
139- { filteredSuggestions . map ( ( item , index ) => (
140- < View key = { `${ item . fromUserId } -${ item . toUserId } -${ index } ` } style = { styles . card } >
141- < Text style = { styles . title } >
142- { labelForUser ( item . fromUserId ) } pays { labelForUser ( item . toUserId ) }
143- </ Text >
144- < Text style = { styles . amount } > ${ ( Number ( item . amountCents ) / 100 ) . toFixed ( 2 ) } </ Text >
145- < View style = { styles . actionRow } >
146- < Button
147- mode = "outlined"
148- style = { styles . actionButton }
149- loading = { remindingIndex === index }
150- disabled = { remindingIndex === index }
151- onPress = { async ( ) => {
152- try {
153- setRemindingIndex ( index ) ;
154- await groupService . remindSettlement ( route . params . groupId , {
155- payerId : item . fromUserId ,
156- receiverId : item . toUserId ,
157- amountCents : item . amountCents ,
158- } ) ;
159- toast ( `Reminder sent to ${ labelForUser ( item . fromUserId ) } ` ) ;
160- } catch {
161- toast ( 'Failed to send reminder' ) ;
162- } finally {
163- setRemindingIndex ( null ) ;
164- }
165- } }
166- >
167- Remind
168- </ Button >
169- < Button
170- mode = "outlined"
171- style = { styles . actionButton }
172- onPress = { async ( ) => {
173- if ( ! upiReceiver . trim ( ) ) {
174- toast ( 'Enter receiver UPI ID first' ) ;
175- return ;
176- }
177- const amount = ( Number ( item . amountCents ) / 100 ) . toFixed ( 2 ) ;
178- const upiLink = `upi://pay?pa=${ encodeURIComponent ( upiReceiver ) } &pn=${ encodeURIComponent (
179- upiName || 'FairShare Member' ,
180- ) } &am=${ encodeURIComponent ( amount ) } &cu=INR`;
181- const supported = await Linking . canOpenURL ( upiLink ) ;
182- if ( ! supported ) {
183- toast ( 'No UPI app found on this device' ) ;
184- return ;
185- }
186- setSelectedIndex ( index ) ;
187- await Linking . openURL ( upiLink ) ;
188- } }
189- >
190- Pay via UPI
191- </ Button >
209+ { filteredSuggestions . map ( ( item , index ) => {
210+ const key = suggestionKey ( item . fromUserId , item . toUserId , item . amountCents ) ;
211+ const latestReminder = latestReminderBySuggestion . get ( key ) ;
212+
213+ return (
214+ < View key = { `${ key } -${ index } ` } style = { styles . card } >
215+ < Text style = { styles . title } >
216+ { labelForUser ( item . fromUserId ) } pays { labelForUser ( item . toUserId ) }
217+ </ Text >
218+ < Text style = { styles . amount } > ${ ( Number ( item . amountCents ) / 100 ) . toFixed ( 2 ) } </ Text >
219+ { latestReminder ? (
220+ < Text style = { styles . reminderStatus } >
221+ Last reminded { relativeTime ( latestReminder . createdAt ) } by { labelForUser ( latestReminder . actorUserId ) }
222+ </ Text >
223+ ) : null }
224+ < View style = { styles . actionRow } >
225+ < Button
226+ mode = "outlined"
227+ style = { styles . actionButton }
228+ loading = { remindingKey === key }
229+ disabled = { remindingKey === key }
230+ onPress = { async ( ) => {
231+ try {
232+ setRemindingKey ( key ) ;
233+ const result = await groupService . remindSettlement ( route . params . groupId , {
234+ payerId : item . fromUserId ,
235+ receiverId : item . toUserId ,
236+ amountCents : item . amountCents ,
237+ } ) ;
238+ setReminderEvents ( ( prev ) => [ result . activity , ...prev . filter ( ( event ) => event . id !== result . activity . id ) ] ) ;
239+ toast ( `Reminder sent to ${ labelForUser ( item . fromUserId ) } ` ) ;
240+ } catch {
241+ toast ( 'Failed to send reminder' ) ;
242+ } finally {
243+ setRemindingKey ( null ) ;
244+ }
245+ } }
246+ >
247+ Remind
248+ </ Button >
249+ < Button
250+ mode = "outlined"
251+ style = { styles . actionButton }
252+ onPress = { async ( ) => {
253+ if ( ! upiReceiver . trim ( ) ) {
254+ toast ( 'Enter receiver UPI ID first' ) ;
255+ return ;
256+ }
257+ const amount = ( Number ( item . amountCents ) / 100 ) . toFixed ( 2 ) ;
258+ const upiLink = `upi://pay?pa=${ encodeURIComponent ( upiReceiver ) } &pn=${ encodeURIComponent (
259+ upiName || 'FairShare Member' ,
260+ ) } &am=${ encodeURIComponent ( amount ) } &cu=INR`;
261+ const supported = await Linking . canOpenURL ( upiLink ) ;
262+ if ( ! supported ) {
263+ toast ( 'No UPI app found on this device' ) ;
264+ return ;
265+ }
266+ setSelectedIndex ( index ) ;
267+ await Linking . openURL ( upiLink ) ;
268+ } }
269+ >
270+ Pay via UPI
271+ </ Button >
272+ </ View >
273+ { selectedIndex === index ? (
274+ < Button
275+ mode = "contained"
276+ style = { { marginTop : 8 } }
277+ onPress = { async ( ) => {
278+ try {
279+ await settlementService . create ( route . params . groupId , {
280+ payerId : item . fromUserId ,
281+ receiverId : item . toUserId ,
282+ amountCents : item . amountCents ,
283+ } ) ;
284+ setSuccessOpen ( true ) ;
285+ setTimeout ( ( ) => {
286+ setSuccessOpen ( false ) ;
287+ toast ( 'Settlement recorded' ) ;
288+ } , 700 ) ;
289+ } catch {
290+ toast ( 'Failed to mark settlement' ) ;
291+ }
292+ } }
293+ >
294+ Mark as paid
295+ </ Button >
296+ ) : null }
192297 </ View >
193- { selectedIndex === index ? (
194- < Button
195- mode = "contained"
196- style = { { marginTop : 8 } }
197- onPress = { async ( ) => {
198- try {
199- await settlementService . create ( route . params . groupId , {
200- payerId : item . fromUserId ,
201- receiverId : item . toUserId ,
202- amountCents : item . amountCents ,
203- } ) ;
204- setSuccessOpen ( true ) ;
205- setTimeout ( ( ) => {
206- setSuccessOpen ( false ) ;
207- toast ( 'Settlement recorded' ) ;
208- } , 700 ) ;
209- } catch {
210- toast ( 'Failed to mark settlement' ) ;
211- }
212- } }
213- >
214- Mark as paid
215- </ Button >
216- ) : null }
217- </ View >
218- ) ) }
298+ ) ;
299+ } ) }
219300 </ ScrollView >
220301
221302 < Modal visible = { successOpen } transparent >
@@ -277,6 +358,31 @@ const styles = StyleSheet.create({
277358 color : '#6B7280' ,
278359 fontWeight : '600' ,
279360 } ,
361+ historyCard : {
362+ marginBottom : spacing . md ,
363+ padding : spacing . md ,
364+ borderRadius : 16 ,
365+ borderWidth : 1 ,
366+ borderColor : 'rgba(148, 163, 184, 0.24)' ,
367+ gap : spacing . sm ,
368+ } ,
369+ historyTitle : {
370+ fontSize : 15 ,
371+ fontWeight : '800' ,
372+ color : '#111827' ,
373+ } ,
374+ historyItem : {
375+ gap : 2 ,
376+ } ,
377+ historyText : {
378+ fontSize : 13 ,
379+ fontWeight : '600' ,
380+ color : '#111827' ,
381+ } ,
382+ historyMeta : {
383+ fontSize : 12 ,
384+ color : '#6B7280' ,
385+ } ,
280386 emptyCard : {
281387 marginBottom : spacing . md ,
282388 padding : spacing . lg ,
@@ -301,6 +407,11 @@ const styles = StyleSheet.create({
301407 fontSize : 14 ,
302408 color : '#6B7280' ,
303409 } ,
410+ reminderStatus : {
411+ fontSize : 12 ,
412+ color : '#6B7280' ,
413+ fontWeight : '600' ,
414+ } ,
304415 actionRow : {
305416 flexDirection : 'row' ,
306417 gap : spacing . sm ,
0 commit comments