From 4e4c93a5a0c65e32f7695c929f845be171c0df7d Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Thu, 1 Oct 2026 16:58:42 +0200 Subject: [PATCH 1/2] feat: add bill management features including bill creation, sharing, and notifications - Implement BillsList component to display user's bills with filtering options. - Create SharePinDialog for PIN entry during bill share actions. - Develop SplitBillComposer for composing and splitting bills among contacts. - Enhance NotificationCenter to handle bill-related notifications. - Update socketService to manage real-time updates for bill statuses. - Extend apiSlice and billSlice to include endpoints for bill operations. --- app/home/bills/[billId]/page.tsx | 36 ++ app/home/bills/page.tsx | 34 ++ app/home/requests/page.tsx | 10 +- app/home/transfer/amount/page.tsx | 71 ++- components/bills/BillDetail.tsx | 482 ++++++++++++++++++ components/bills/BillsList.tsx | 153 ++++++ components/bills/SharePinDialog.tsx | 112 ++++ components/bills/SplitBillComposer.tsx | 273 ++++++++++ .../notifications/NotificationCenter.tsx | 14 +- services/socketService.ts | 23 + states/apiSlice.ts | 2 +- states/billSlice.ts | 105 ++++ 12 files changed, 1305 insertions(+), 10 deletions(-) create mode 100644 app/home/bills/[billId]/page.tsx create mode 100644 app/home/bills/page.tsx create mode 100644 components/bills/BillDetail.tsx create mode 100644 components/bills/BillsList.tsx create mode 100644 components/bills/SharePinDialog.tsx create mode 100644 components/bills/SplitBillComposer.tsx create mode 100644 states/billSlice.ts diff --git a/app/home/bills/[billId]/page.tsx b/app/home/bills/[billId]/page.tsx new file mode 100644 index 0000000..b435f2d --- /dev/null +++ b/app/home/bills/[billId]/page.tsx @@ -0,0 +1,36 @@ +"use client"; +import React from "react"; +import { useParams } from "next/navigation"; +import Navigation from "@/components/Navigation"; +import { Header } from "@/components/Header"; +import BillDetail from "@/components/bills/BillDetail"; +import { useSidebar } from "@/context/SidebarContext"; +import { cn } from "@/lib/utils"; +import { useAccent } from "@/hooks/use-accent"; + +const BillDetailPage = () => { + const { isExpanded } = useSidebar(); + const accent = useAccent(); + const params = useParams<{ billId: string }>(); + + return ( +
+ +
+
+
+
+ {params?.billId && } +
+
+
+
+ ); +}; + +export default BillDetailPage; diff --git a/app/home/bills/page.tsx b/app/home/bills/page.tsx new file mode 100644 index 0000000..1e3740c --- /dev/null +++ b/app/home/bills/page.tsx @@ -0,0 +1,34 @@ +"use client"; +import React from "react"; +import Navigation from "@/components/Navigation"; +import { Header } from "@/components/Header"; +import BillsList from "@/components/bills/BillsList"; +import { useSidebar } from "@/context/SidebarContext"; +import { cn } from "@/lib/utils"; +import { useAccent } from "@/hooks/use-accent"; + +const BillsPage = () => { + const { isExpanded } = useSidebar(); + const accent = useAccent(); + + return ( +
+ +
+
+
+
+ +
+
+
+
+ ); +}; + +export default BillsPage; diff --git a/app/home/requests/page.tsx b/app/home/requests/page.tsx index b5c8095..8c9ff3c 100644 --- a/app/home/requests/page.tsx +++ b/app/home/requests/page.tsx @@ -13,7 +13,7 @@ import { Card } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { HandCoins, ArrowUpRight, ArrowDownLeft, Clock, CheckCircle2, XCircle, ChevronRight } from "lucide-react" +import { HandCoins, ArrowUpRight, ArrowDownLeft, Clock, CheckCircle2, XCircle, ChevronRight, Receipt } from "lucide-react" import { formatDistanceToNow } from "date-fns" import { UserAvatar } from "@/components/UserAvatar" import type { PaymentRequest } from "@/types/dashboard" @@ -194,10 +194,16 @@ export default function RequestsPage() {
-
+

Money Requests

Manage your pending payments

+
diff --git a/app/home/transfer/amount/page.tsx b/app/home/transfer/amount/page.tsx index 804fe2d..7d532e7 100644 --- a/app/home/transfer/amount/page.tsx +++ b/app/home/transfer/amount/page.tsx @@ -28,7 +28,8 @@ import StepIndicator from "@/components/transfer/StepIndicator"; import RequestLoadingSkeleton from "@/components/transfer/RequestLoadingSkeleton"; import { PinSetupModal } from "@/components/PinSetupModal"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Loader2, AlertCircle, ScanLine, CalendarClock } from "lucide-react"; +import SplitBillComposer from "@/components/bills/SplitBillComposer"; +import { Loader2, AlertCircle, ScanLine, CalendarClock, Split } from "lucide-react"; import { isTokenExpired, getUserIdFromToken } from "@/utils/jwtUtils"; import { useAccent } from "@/hooks/use-accent"; @@ -97,6 +98,7 @@ const AmountPageInner = () => { const [selectedCategory, setSelectedCategory] = useState(null); const [applyConstraints, setApplyConstraints] = useState(false); const [schedule, setSchedule] = useState(defaultScheduleState); + const [splitEnabled, setSplitEnabled] = useState(false); const [isOrganization, setIsOrganization] = useState(false); const [organizationCategory, setOrganizationCategory] = useState(null); @@ -222,7 +224,9 @@ const AmountPageInner = () => { setError("Minimum amount is RWF 100"); return; } - if (currentBalance !== null && numAmount > currentBalance) { + // A split doesn't move money yet, and each person pays only their own share, + // so the full amount needn't fit in the organizer's balance. + if (!splitEnabled && currentBalance !== null && numAmount > currentBalance) { setError("Insufficient balance"); return; } @@ -434,6 +438,17 @@ const AmountPageInner = () => { const isQRFlow = !!requestId && !!requestMeta; const allowEditAmount = requestMeta ? requestMeta.allowEditAmount : true; + // Splitting is a person-to-person/business feature: not for payment requests, scheduled + // transfers, raw wallet ids, or when sending from an organization/sub-action wallet. + const selectedSender = getSelectedSenderFromSession(); + const canSplit = + !isQRFlow && + !schedule.enabled && + recipient.type !== "wallet" && + !recipient.walletId && + (!selectedSender || selectedSender.type === "user"); + const splitActive = splitEnabled && canSplit; + return (
@@ -516,8 +531,46 @@ const AmountPageInner = () => { /> )} + {canSplit && ( + + )} + {/* Scheduling — hidden for request flows, which need immediate fulfillment */} - {!isQRFlow && ( + {!isQRFlow && !splitActive && ( { /> )} - {/* Category selector — hidden for request flows */} - {!isQRFlow && ( + {/* Category selector — hidden for request flows and splits */} + {!isQRFlow && !splitActive && ( { onClick={handleContinue} className="w-full bg-brand-green dark:bg-brand-gold text-white dark:text-darkBg-main font-bold py-4 rounded-2xl shadow-lg hover:opacity-90 transition-all flex items-center justify-center gap-2" > - Continue to PIN + {splitActive ? "Continue to split" : "Continue to PIN"} {isQRFlow && ( @@ -561,6 +614,12 @@ const AmountPageInner = () => { )}
+ ) : splitActive ? ( + setStep(1)} + /> ) : (
{/* Summary card above PIN */} diff --git a/components/bills/BillDetail.tsx b/components/bills/BillDetail.tsx new file mode 100644 index 0000000..8515196 --- /dev/null +++ b/components/bills/BillDetail.tsx @@ -0,0 +1,482 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Checkbox } from "@/components/ui/checkbox" +import { Progress } from "@/components/ui/progress" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Receipt, Loader2, CheckCircle, Clock, Ban, Bell, HandCoins, Repeat, XCircle } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import { useAuthToken } from "@/hooks/use-auth-token" +import { getCurrentUserId } from "@/utils/tokenUtils" +import { useGetAcceptedContactsQuery } from "@/states/contactSlice" +import { + useGetBillByIdQuery, + usePayBillShareMutation, + useDeclineBillShareMutation, + useCoverBillShareMutation, + useReassignBillShareMutation, + useCloseBillShareMutation, + useRemindBillShareMutation, + useCancelBillMutation, +} from "@/states/billSlice" +import socketService from "@/services/socketService" +import { useAccent } from "@/hooks/use-accent" +import SharePinDialog from "@/components/bills/SharePinDialog" + +interface BillShare { + id: string + payerId: string + payerName: string + amount: number + status: "pending" | "paid" | "covered" | "declined" | "closed" | "cancelled" + paidByUserId: string | null + paidByName: string | null + declineNote: string | null +} + +// Surfaces follow the app's dark tokens (same as Money Requests) instead of the neutral shadcn defaults. +const CARD = "bg-white dark:bg-darkBg-card border-gray-100 dark:border-darkBorder-light" +const OUTLINE_BTN = "border-gray-200 dark:border-darkBorder-light bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-darkBg-interactive" + +const fmt = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 2 }) +const isOpenShare = (status: string) => status === "pending" || status === "declined" + +const STATUS_STYLES: Record = { + pending: { label: "Waiting", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" }, + paid: { label: "Paid", className: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" }, + covered: { label: "Covered", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" }, + declined: { label: "Declined", className: "bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400" }, + closed: { label: "Settled outside app", className: "bg-gray-100 text-gray-500 dark:bg-darkBg-interactive dark:text-gray-300" }, + cancelled: { label: "Cancelled", className: "bg-gray-100 text-gray-500 dark:bg-darkBg-interactive dark:text-gray-300" }, +} + +const StatusPill = ({ status }: { status: string }) => { + const style = STATUS_STYLES[status] || STATUS_STYLES.pending + return ( + + {style.label} + + ) +} + +type ConfirmState = { type: "close"; share: BillShare } | { type: "cancel" } | null + +export default function BillDetail({ billId }: { billId: string }) { + const router = useRouter() + const { getToken } = useAuthToken() + const token = getToken() + const myId = getCurrentUserId() + const accent = useAccent() + + const { data: resp, isLoading, isError, refetch } = useGetBillByIdQuery(billId) + const [payShare] = usePayBillShareMutation() + const [declineShare, { isLoading: declining }] = useDeclineBillShareMutation() + const [coverShare] = useCoverBillShareMutation() + const [reassignShare, { isLoading: reassigning }] = useReassignBillShareMutation() + const [closeShare, { isLoading: closing }] = useCloseBillShareMutation() + const [remindShare] = useRemindBillShareMutation() + const [cancelBill, { isLoading: cancelling }] = useCancelBillMutation() + + const [payOpen, setPayOpen] = useState(false) + const [coverTarget, setCoverTarget] = useState(null) + const [askReimbursement, setAskReimbursement] = useState(true) + const [declineOpen, setDeclineOpen] = useState(false) + const [declineNote, setDeclineNote] = useState("") + const [reassignTarget, setReassignTarget] = useState(null) + const [confirm, setConfirm] = useState(null) + + const { data: contactsData } = useGetAcceptedContactsQuery(token!, { skip: !token || !reassignTarget }) + + useEffect(() => { + const onUpdate = (data: { billId: string }) => { + if (data.billId === billId) refetch() + } + socketService.onBillUpdated(onUpdate) + return () => socketService.offBillUpdated(onUpdate) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [billId]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + const bill = resp?.data + if (isError || !bill) { + return ( +
+

This split isn't available anymore.

+ +
+ ) + } + + const shares: BillShare[] = bill.shares + const isOrganizer = bill.role === "organizer" + const billOpen = bill.status === "open" + const myShare = shares.find((s) => s.id === bill.myShareId) || null + const recipientIsMe = bill.recipient?.type === "user" && bill.recipient.id === myId + const pct = bill.totalAmount > 0 ? Math.min(100, (bill.paidInAmount / bill.totalAmount) * 100) : 0 + + const canCover = (share: BillShare) => + billOpen && isOpenShare(share.status) && share.payerId !== myId && !recipientIsMe + + const takeAction = async (label: string, work: () => Promise) => { + try { + await work() + toast({ title: label }) + } catch (err: any) { + toast({ title: "Could not do that", description: err?.data?.message || "Something went wrong", variant: "destructive" }) + } + } + + const existingPayerIds = new Set(shares.map((s) => s.payerId)) + const reassignCandidates = (contactsData?.contacts || []) + .map((c: any) => ({ + id: c.otherUser.id, + name: `${c.otherUser.firstName} ${c.otherUser.lastName}`, + avatar: c.otherUser.avatar, + })) + .filter((c: any) => !existingPayerIds.has(c.id) && !(bill.recipient?.type === "user" && bill.recipient.id === c.id)) + + return ( + <> +
+
+ +
+
+

{bill.title}

+

+ To {bill.recipient?.name ?? "recipient"} + {bill.recipient?.type === "organization" && " (business)"} · organized by{" "} + {isOrganizer ? "you" : bill.organizer.name} +

+
+
+ + {bill.note &&

"{bill.note}"

} + + +
+
+

Paid in the app

+

+ {fmt(bill.paidInAmount)} of {fmt(bill.totalAmount)} {bill.currency} +

+
+ {bill.status === "completed" && ( + + Settled + + )} + {bill.status === "cancelled" && ( + + Cancelled + + )} +
+ + {bill.status === "completed" && bill.paidInAmount < bill.totalAmount && ( +

+ The remaining {fmt(bill.totalAmount - bill.paidInAmount)} {bill.currency} was settled outside the app. +

+ )} +
+ + {myShare && billOpen && isOpenShare(myShare.status) && ( + +

Your share

+

+ {fmt(myShare.amount)} {bill.currency} +

+

+ Goes straight to {bill.recipient?.name}. Nothing passes through {isOrganizer ? "anyone else" : bill.organizer.name}. +

+
+ {myShare.status === "pending" && ( + + )} + +
+ {myShare.status === "declined" && ( +

You declined this. You can still pay it until it is resolved.

+ )} +
+ )} + +
+ {shares.map((share) => ( + +
+
+

+ {share.payerId === myId ? "You" : share.payerName} +

+ {share.status === "covered" && share.paidByName && ( +

covered by {share.paidByUserId === myId ? "you" : share.paidByName}

+ )} + {share.status === "declined" && share.declineNote && ( +

"{share.declineNote}"

+ )} +
+
+ + {fmt(share.amount)} {bill.currency} + + +
+
+ + {billOpen && isOpenShare(share.status) && (canCover(share) || isOrganizer) && ( +
+ {canCover(share) && ( + + )} + {isOrganizer && ( + <> + + + + + )} +
+ )} +
+ ))} +
+ + {bill.canCancel && ( + + )} + {isOrganizer && billOpen && !bill.canCancel && ( +

+ Some shares are already paid, so this split can't be cancelled. Mark the rest as settled outside the app to close it. +

+ )} + + {/* Pay my own share */} + {myShare && ( + setPayOpen(false)} + title="Pay your share" + description={`${fmt(myShare.amount)} ${bill.currency} goes straight to ${bill.recipient?.name}.`} + confirmLabel="Pay" + onConfirm={async (pin) => { + await payShare({ billId, shareId: myShare.id, pin }).unwrap() + toast({ title: "Share paid" }) + }} + /> + )} + + {/* Cover someone else's share */} + {coverTarget && ( + setCoverTarget(null)} + title={`Cover ${coverTarget.payerName}'s share`} + description={`You'll pay ${fmt(coverTarget.amount)} ${bill.currency} to ${bill.recipient?.name} so the bill can be settled.`} + confirmLabel="Cover" + onConfirm={async (pin) => { + await coverShare({ + billId, + shareId: coverTarget.id, + pin, + requestReimbursement: askReimbursement, + }).unwrap() + toast({ + title: "Share covered", + description: askReimbursement ? `${coverTarget.payerName} was asked to pay you back.` : undefined, + }) + }} + > + + + )} + + {/* Decline my share */} + !open && setDeclineOpen(false)}> + + + Decline your share? + + {bill.organizer.name} will be told, so it can be sorted out. You can still pay it later. + + + setDeclineNote(e.target.value)} + /> + + + + + + + + {/* Reassign a share */} + !open && setReassignTarget(null)}> + + + Reassign this share + + Give {reassignTarget?.payerName}'s {reassignTarget ? fmt(reassignTarget.amount) : ""} {bill.currency} share to + someone else. The amount stays the same. + + + {reassignCandidates.length === 0 ? ( +

No other contacts available.

+ ) : ( +
+ {reassignCandidates.map((c: any) => ( + + ))} +
+ )} +
+
+ + {/* Confirm: settled outside the app / cancel the split */} + !open && setConfirm(null)}> + + + + {confirm?.type === "cancel" ? "Cancel this split?" : "Mark as settled outside the app?"} + + + {confirm?.type === "cancel" + ? "Everyone with an unpaid share will be told. Nothing has been paid yet, so no money moves." + : confirm + ? `${confirm.share.payerName}'s ${fmt(confirm.share.amount)} ${bill.currency} share stops counting toward what's collected in the app. Only do this if it was settled some other way, like cash.` + : ""} + + + + + Back + + { + e.preventDefault() + if (!confirm) return + if (confirm.type === "cancel") { + await takeAction("Split cancelled", () => cancelBill(billId).unwrap()) + } else { + await takeAction("Marked as settled outside the app", () => + closeShare({ billId, shareId: confirm.share.id }).unwrap() + ) + } + setConfirm(null) + }} + > + {(closing || cancelling) && } + {confirm?.type === "cancel" ? "Cancel split" : "Confirm"} + + + + + + ) +} diff --git a/components/bills/BillsList.tsx b/components/bills/BillsList.tsx new file mode 100644 index 0000000..850a13e --- /dev/null +++ b/components/bills/BillsList.tsx @@ -0,0 +1,153 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Receipt, Loader2 } from "lucide-react" +import { Card } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { useGetMyBillsQuery } from "@/states/billSlice" +import { useAccent } from "@/hooks/use-accent" +import socketService from "@/services/socketService" + +interface BillSummary { + id: string + title: string + status: "open" | "completed" | "cancelled" + currency: string + totalAmount: number + paidInAmount: number + recipient: { id: string; name: string; type: string } | null + organizer: { id: string; name: string } + role: "organizer" | "payer" + sharesCount: number + myShare: { id: string; amount: number; status: string } | null + createdAt: string +} + +type Filter = "all" | "organized" | "owe" + +const fmt = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 2 }) + +const BILL_STATUS: Record = { + open: { label: "Open", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" }, + completed: { label: "Settled", className: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" }, + cancelled: { label: "Cancelled", className: "bg-gray-100 text-gray-500 dark:bg-darkBg-interactive dark:text-gray-300" }, +} + +const SHARE_LABEL: Record = { + pending: "waiting for you", + declined: "declined", + paid: "paid", + covered: "covered", + closed: "settled outside the app", + cancelled: "cancelled", +} + +export default function BillsList() { + const router = useRouter() + const { data, isLoading, refetch } = useGetMyBillsQuery(undefined) + const [filter, setFilter] = useState("all") + const accent = useAccent() + + useEffect(() => { + const onUpdate = () => refetch() + socketService.onBillUpdated(onUpdate) + return () => socketService.offBillUpdated(onUpdate) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const bills: BillSummary[] = data?.data ?? [] + const visible = bills.filter((b) => { + if (filter === "organized") return b.role === "organizer" + if (filter === "owe") { + return b.status === "open" && !!b.myShare && (b.myShare.status === "pending" || b.myShare.status === "declined") + } + return true + }) + + const filters: { key: Filter; label: string }[] = [ + { key: "all", label: "All" }, + { key: "organized", label: "I organized" }, + { key: "owe", label: "I owe" }, + ] + + return ( + <> +
+
+ +
+
+

Split payments

+

+ Start one from Send Money by turning on "Split this payment". +

+
+
+ +
+ {filters.map((f) => ( + + ))} +
+ + {isLoading ? ( +
+ +
+ ) : visible.length === 0 ? ( +
+ +

Nothing here yet.

+
+ ) : ( +
+ {visible.map((b) => { + const pct = b.totalAmount > 0 ? Math.min(100, (b.paidInAmount / b.totalAmount) * 100) : 0 + const status = BILL_STATUS[b.status] + return ( + router.push(`/home/bills/${b.id}`)} + className="p-4 cursor-pointer hover:shadow-md transition-all border-gray-100 dark:border-darkBorder-light bg-white dark:bg-darkBg-card dark:hover:bg-darkBg-interactive" + > +
+
+

{b.title}

+

+ To {b.recipient?.name ?? "recipient"} · {b.role === "organizer" ? "you organized" : `by ${b.organizer.name}`} +

+
+ + {status.label} + +
+ +
+ + {fmt(b.paidInAmount)} of {fmt(b.totalAmount)} {b.currency} paid + + {b.myShare && ( + + Your share {fmt(b.myShare.amount)} · {SHARE_LABEL[b.myShare.status] || b.myShare.status} + + )} +
+
+ ) + })} +
+ )} + + ) +} diff --git a/components/bills/SharePinDialog.tsx b/components/bills/SharePinDialog.tsx new file mode 100644 index 0000000..1c49b8b --- /dev/null +++ b/components/bills/SharePinDialog.tsx @@ -0,0 +1,112 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import PinEntry from "@/components/transfer/PinEntry" +import { PinSetupModal } from "@/components/PinSetupModal" + +interface SharePinDialogProps { + isOpen: boolean + onClose: () => void + title: string + description: string + confirmLabel: string + /** Extra options shown above the PIN pad (e.g. the "ask them to pay me back" checkbox). */ + children?: React.ReactNode + /** Should reject with the RTK error so a missing PIN can open the setup modal. */ + onConfirm: (pin: string) => Promise +} + +export default function SharePinDialog({ + isOpen, + onClose, + title, + description, + confirmLabel, + children, + onConfirm, +}: SharePinDialogProps) { + const [pin, setPin] = useState("") + const [showPin, setShowPin] = useState(false) + const [pinError, setPinError] = useState("") + const [submitting, setSubmitting] = useState(false) + const [showPinSetup, setShowPinSetup] = useState(false) + + useEffect(() => { + if (isOpen) { + setPin("") + setPinError("") + } + }, [isOpen]) + + const handleConfirm = async () => { + if (pin.length !== 4) { + setPinError("Enter your 4-digit PIN") + return + } + setPinError("") + setSubmitting(true) + try { + await onConfirm(pin) + onClose() + } catch (error: any) { + const errorData = error?.data || {} + if (errorData.requiresPinSetup) { + setShowPinSetup(true) + toast({ + title: "PIN Setup Required", + description: errorData.message || "Please set up your transaction PIN first", + variant: "destructive", + }) + } else { + setPinError(errorData.message || "Something went wrong") + } + setPin("") + } finally { + setSubmitting(false) + } + } + + return ( + <> + { + toast({ title: "PIN Setup Complete", description: "Enter your PIN to continue." }) + setShowPinSetup(false) + }} + /> + + !open && !submitting && onClose()}> + + + {title} + + {description} + + + + {children} + + + + + + + + + + + ) +} diff --git a/components/bills/SplitBillComposer.tsx b/components/bills/SplitBillComposer.tsx new file mode 100644 index 0000000..e4a31b0 --- /dev/null +++ b/components/bills/SplitBillComposer.tsx @@ -0,0 +1,273 @@ +"use client" + +import React, { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { Loader2, Search, RotateCcw } from "lucide-react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { toast } from "@/hooks/use-toast" +import { useAuthToken } from "@/hooks/use-auth-token" +import { getCurrentUserId } from "@/utils/tokenUtils" +import { useGetAcceptedContactsQuery } from "@/states/contactSlice" +import { useCreateBillMutation } from "@/states/billSlice" + +interface SplitRecipient { + id: string + name: string + type?: string +} + +interface SplitBillComposerProps { + recipient: SplitRecipient + total: number + currency?: string + onBack: () => void +} + +interface ContactRow { + id: string + name: string + avatar?: string +} + +const initials = (name: string) => + name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) + +const fmt = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 2 }) + +// Amounts are handled in minor units (cents) so an uneven split never drifts off the total. +const toInput = (minor: number) => { + const value = minor / 100 + return Number.isInteger(value) ? String(value) : value.toFixed(2) +} +const toMinor = (raw: string | undefined) => { + const value = parseFloat(raw || "") + return Number.isFinite(value) ? Math.round(value * 100) : 0 +} + +export default function SplitBillComposer({ recipient, total, currency = "RWF", onBack }: SplitBillComposerProps) { + const router = useRouter() + const { getToken } = useAuthToken() + const token = getToken() + const myId = getCurrentUserId() + + const [title, setTitle] = useState("Split bill") + const [note, setNote] = useState("") + const [includeMe, setIncludeMe] = useState(true) + const [selectedIds, setSelectedIds] = useState([]) + const [amounts, setAmounts] = useState>({}) + const [customized, setCustomized] = useState(false) + const [search, setSearch] = useState("") + + const [createBill, { isLoading: creating }] = useCreateBillMutation() + const { data: contactsData, isLoading: loadingContacts } = useGetAcceptedContactsQuery(token!, { skip: !token }) + + const contacts: ContactRow[] = useMemo( + () => + (contactsData?.contacts || []) + .map((c: any) => ({ + id: c.otherUser.id, + name: `${c.otherUser.firstName} ${c.otherUser.lastName}`, + avatar: c.otherUser.avatar, + })) + .filter((c: ContactRow) => c.id !== recipient.id), + [contactsData, recipient.id] + ) + + const filteredContacts = contacts.filter((c) => c.name.toLowerCase().includes(search.toLowerCase())) + const nameOf = (id: string) => (id === myId ? "You" : contacts.find((c) => c.id === id)?.name || "Someone") + + const participantIds = useMemo( + () => [...(includeMe && myId ? [myId] : []), ...selectedIds], + [includeMe, myId, selectedIds] + ) + + const totalMinor = Math.round(total * 100) + + // Even split by default; the leftover minor units go to the first few people so the shares + // always add up to the total exactly. + useEffect(() => { + if (customized) return + const count = participantIds.length + if (count === 0) { + setAmounts({}) + return + } + const base = Math.floor(totalMinor / count) + const remainder = totalMinor - base * count + const next: Record = {} + participantIds.forEach((id, i) => { + next[id] = toInput(base + (i < remainder ? 1 : 0)) + }) + setAmounts(next) + }, [participantIds, totalMinor, customized]) + + const assignedMinor = participantIds.reduce((sum, id) => sum + toMinor(amounts[id]), 0) + const remainingMinor = totalMinor - assignedMinor + const everyShareHasAmount = participantIds.every((id) => toMinor(amounts[id]) > 0) + const canSubmit = + participantIds.length >= 1 && + remainingMinor === 0 && + everyShareHasAmount && + title.trim().length > 0 && + !creating + + // Changing who's in the split invalidates any hand-edited amounts, so go back to even. + const toggleContact = (id: string) => { + setCustomized(false) + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + } + + const handleAmountChange = (id: string, value: string) => { + setCustomized(true) + setAmounts((prev) => ({ ...prev, [id]: value.replace(/[^\d.]/g, "") })) + } + + const handleSubmit = async () => { + if (!canSubmit) return + try { + const res: any = await createBill({ + ...(recipient.type === "organization" + ? { recipientOrganizationId: recipient.id } + : { recipientUserId: recipient.id }), + totalAmount: total, + title: title.trim(), + note: note.trim() || undefined, + shares: participantIds.map((id) => ({ payerId: id, amount: toMinor(amounts[id]) / 100 })), + }).unwrap() + toast({ title: "Split created", description: "Everyone has been notified of their share." }) + router.push(`/home/bills/${res.data.id}`) + } catch (err: any) { + toast({ + title: "Could not create the split", + description: err?.data?.message || "Something went wrong", + variant: "destructive", + }) + } + } + + const cardClass = + "bg-white dark:bg-darkBg-card rounded-2xl border border-gray-100 dark:border-darkBorder-light p-4 mb-4" + + return ( +
+
+

Splitting

+

+ {currency} {fmt(total)} +

+

+ Everyone pays their own share directly to {recipient.name}. +

+
+ +
+ + setTitle(e.target.value)} /> + + setNote(e.target.value)} /> +
+ +
+

Who is splitting it?

+ + + +
+ + setSearch(e.target.value)} /> +
+ +
+ {loadingContacts ? ( +
+ Loading contacts... +
+ ) : filteredContacts.length === 0 ? ( +

No contacts to show.

+ ) : ( + filteredContacts.map((c) => ( + + )) + )} +
+
+ + {participantIds.length > 0 && ( +
+
+

Shares

+ {customized && ( + + )} +
+ +
+ {participantIds.map((id) => ( +
+ {nameOf(id)} +
+ handleAmountChange(id, e.target.value)} + /> + {currency} +
+
+ ))} +
+ +
+ + Assigned {fmt(assignedMinor / 100)} of {fmt(total)} + + + {remainingMinor === 0 + ? "Adds up" + : remainingMinor > 0 + ? `${fmt(remainingMinor / 100)} left to assign` + : `${fmt(Math.abs(remainingMinor) / 100)} over`} + +
+
+ )} + + +

+ No money moves yet. Each person pays their own share (with their own PIN) from the split page. +

+ + +
+ ) +} diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index cb82468..17d3c5b 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -8,7 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" -import { Bell, Users, UserPlus, Check, X, Clock, AlertCircle, Info, Loader2, MessageCircle, Image, Video, Music, File, DollarSign, UserMinus, UserCheck, UserX, ShieldCheck, ShieldAlert, Settings, Trash2, HandCoins, WalletCards } from "lucide-react" +import { Bell, Users, UserPlus, Check, X, Clock, AlertCircle, Info, Loader2, MessageCircle, Image, Video, Music, File, DollarSign, UserMinus, UserCheck, UserX, ShieldCheck, ShieldAlert, Settings, Trash2, HandCoins, WalletCards, Receipt } from "lucide-react" import { toast } from "@/hooks/use-toast" import { useNotifications } from "@/context/NotificationContext" import { useAuthToken } from "@/hooks/use-auth-token" @@ -152,6 +152,18 @@ const NotificationCenter: React.FC = ({ isOpen, onClose case 'SHARED_WALLET_POLICY_CHANGE_DECLINED': return + // Split payment (bill) notifications + case 'BILL_SHARE_ASSIGNED': + case 'BILL_REMINDER': + return + case 'BILL_SHARE_PAID': + case 'BILL_SHARE_COVERED': + case 'BILL_COMPLETED': + return + case 'BILL_SHARE_DECLINED': + case 'BILL_CANCELLED': + return + // Contact notifications case 'CONTACT_REQUEST_RECEIVED': case 'contact_request': diff --git a/services/socketService.ts b/services/socketService.ts index 24bb6cd..ac4efec 100644 --- a/services/socketService.ts +++ b/services/socketService.ts @@ -823,6 +823,29 @@ class SocketService { } } + // Split payment (bill) updates: a share was paid/covered/declined/reassigned, or the bill + // was completed/cancelled. Fans out to the organizer and every payer. + onBillUpdated(callback: (data: { + billId: string; + status?: string; + shareId?: string; + shareStatus?: string; + }) => void) { + if (this.socket) { + this.socket.on("bill_updated", callback) + } + } + + offBillUpdated(callback?: (data: any) => void) { + if (this.socket) { + if (callback) { + this.socket.off("bill_updated", callback) + } else { + this.socket.off("bill_updated") + } + } + } + // Listen for shared wallet policy change proposal updates (vote cast, approved, etc.) onSharedWalletPolicyChangeUpdated(callback: (data: { sharedWalletId: string; diff --git a/states/apiSlice.ts b/states/apiSlice.ts index d19534d..9334dbc 100644 --- a/states/apiSlice.ts +++ b/states/apiSlice.ts @@ -46,6 +46,6 @@ const baseQueryWithReauth = async (args: any, api: any, extraOptions: any) => { export const apiSlice = createApi({ reducerPath: 'api', baseQuery: baseQueryWithReauth, - tagTypes: ['Group', 'GroupMember', 'GroupInvitation', 'GroupJoinRequest', 'Notification', 'Contact', 'ContactInvitation', 'User', 'Chat', 'ChatMessage', 'ChatStatus', 'Wallet', 'Escrow', 'Poll', 'SharedNote', 'Whiteboard', 'PinnedMessage', 'SharedWalletWithdrawal', 'SharedWallets', 'SharedWalletInvitations', 'SharedWalletPolicyChange'], + tagTypes: ['Group', 'GroupMember', 'GroupInvitation', 'GroupJoinRequest', 'Notification', 'Contact', 'ContactInvitation', 'User', 'Chat', 'ChatMessage', 'ChatStatus', 'Wallet', 'Escrow', 'Poll', 'SharedNote', 'Whiteboard', 'PinnedMessage', 'SharedWalletWithdrawal', 'SharedWallets', 'SharedWalletInvitations', 'SharedWalletPolicyChange', 'Bills'], endpoints: () => ({}) }) diff --git a/states/billSlice.ts b/states/billSlice.ts new file mode 100644 index 0000000..b55d21f --- /dev/null +++ b/states/billSlice.ts @@ -0,0 +1,105 @@ +import { apiSlice } from "@/states/apiSlice"; + +// Mutations invalidate the whole "Bills" tag on purpose: a payment changes both the bill +// detail and every list that shows its progress. +export const billSlice = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + getMyBills: builder.query({ + query: () => `/bills`, + providesTags: ["Bills"], + }), + + getBillById: builder.query({ + query: (billId: string) => `/bills/${billId}`, + providesTags: (result, error, billId) => [{ type: "Bills", id: billId }], + }), + + createBill: builder.mutation({ + query: (body: { + recipientUserId?: string + recipientOrganizationId?: string + totalAmount: number + title?: string + note?: string + shares: { payerId: string; amount: number }[] + }) => ({ + url: `/bills`, + method: "POST", + body, + }), + invalidatesTags: ["Bills"], + }), + + payBillShare: builder.mutation({ + query: ({ billId, shareId, pin }) => ({ + url: `/bills/${billId}/shares/${shareId}/pay`, + method: "POST", + body: { pin }, + }), + invalidatesTags: ["Bills"], + }), + + declineBillShare: builder.mutation({ + query: ({ billId, shareId, note }) => ({ + url: `/bills/${billId}/shares/${shareId}/decline`, + method: "POST", + body: { note }, + }), + invalidatesTags: ["Bills"], + }), + + coverBillShare: builder.mutation({ + query: ({ billId, shareId, pin, requestReimbursement }) => ({ + url: `/bills/${billId}/shares/${shareId}/cover`, + method: "POST", + body: { pin, requestReimbursement }, + }), + invalidatesTags: ["Bills"], + }), + + reassignBillShare: builder.mutation({ + query: ({ billId, shareId, newPayerId }) => ({ + url: `/bills/${billId}/shares/${shareId}/reassign`, + method: "POST", + body: { newPayerId }, + }), + invalidatesTags: ["Bills"], + }), + + closeBillShare: builder.mutation({ + query: ({ billId, shareId }) => ({ + url: `/bills/${billId}/shares/${shareId}/close`, + method: "POST", + }), + invalidatesTags: ["Bills"], + }), + + remindBillShare: builder.mutation({ + query: ({ billId, shareId }) => ({ + url: `/bills/${billId}/shares/${shareId}/remind`, + method: "POST", + }), + }), + + cancelBill: builder.mutation({ + query: (billId: string) => ({ + url: `/bills/${billId}/cancel`, + method: "POST", + }), + invalidatesTags: ["Bills"], + }), + }), +}); + +export const { + useGetMyBillsQuery, + useGetBillByIdQuery, + useCreateBillMutation, + usePayBillShareMutation, + useDeclineBillShareMutation, + useCoverBillShareMutation, + useReassignBillShareMutation, + useCloseBillShareMutation, + useRemindBillShareMutation, + useCancelBillMutation, +} = billSlice; From ce3f7e55d2c50c8508ee375008885011d36a4988 Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Tue, 13 Oct 2026 15:20:56 +0200 Subject: [PATCH 2/2] feat: enhance split bill functionality and UI updates --- .../components/ContactDetailsPanel.tsx | 3 +- app/home/requests/page.tsx | 2 +- app/home/transfer/amount/page.tsx | 4 +- components/Dashboard/TransactionList.tsx | 92 +++++--- components/RecentTransactions.tsx | 36 ++- components/Wallet/RecentActivity.tsx | 37 +++- components/bills/BillDetail.tsx | 29 ++- components/bills/FindPersonByLink.tsx | 208 ++++++++++++++++++ components/bills/SplitBillComposer.tsx | 28 ++- components/bills/SplitBillHistoryDialog.tsx | 147 +++++++++++++ .../notifications/NotificationCenter.tsx | 8 +- types/dashboard.ts | 31 +++ 12 files changed, 573 insertions(+), 52 deletions(-) create mode 100644 components/bills/FindPersonByLink.tsx create mode 100644 components/bills/SplitBillHistoryDialog.tsx diff --git a/app/contacts/components/ContactDetailsPanel.tsx b/app/contacts/components/ContactDetailsPanel.tsx index f209ca1..783b584 100644 --- a/app/contacts/components/ContactDetailsPanel.tsx +++ b/app/contacts/components/ContactDetailsPanel.tsx @@ -294,7 +294,8 @@ export function ContactDetailsPanel({ contact, isOpen, onClose }: ContactDetails ? "text-gray-900 dark:text-white" : "text-brand-green dark:text-brand-gold" )}> - {txn.senderWallet?.userId === userId ? '-' : '+'}RWF {parseFloat(txn.amount).toLocaleString()} + {/* A covered split share came from their wallet, not yours: no +/- */} + {txn.coveredShare ? '' : txn.senderWallet?.userId === userId ? '-' : '+'}RWF {parseFloat(txn.amount).toLocaleString()} )) diff --git a/app/home/requests/page.tsx b/app/home/requests/page.tsx index 8c9ff3c..ed604a4 100644 --- a/app/home/requests/page.tsx +++ b/app/home/requests/page.tsx @@ -242,7 +242,7 @@ export default function RequestsPage() { {sentRequests.length === 0 ? (
-

You haven't sent any payment requests yet

+

You haven't sent any payment requests yet

) : ( sentRequests.map(req => ( diff --git a/app/home/transfer/amount/page.tsx b/app/home/transfer/amount/page.tsx index 7d532e7..37efd08 100644 --- a/app/home/transfer/amount/page.tsx +++ b/app/home/transfer/amount/page.tsx @@ -406,7 +406,7 @@ const AmountPageInner = () => {

- Couldn't load request + Couldn't load request

{error || "No request details found. The link may be invalid or expired."} @@ -479,7 +479,7 @@ const AmountPageInner = () => { {" "}is requesting payment

{requestMeta.note && ( -

"{requestMeta.note}"

+

“{requestMeta.note}”

)} diff --git a/components/Dashboard/TransactionList.tsx b/components/Dashboard/TransactionList.tsx index 89a0cea..ff9b95e 100644 --- a/components/Dashboard/TransactionList.tsx +++ b/components/Dashboard/TransactionList.tsx @@ -12,6 +12,7 @@ import { useGetAcceptedContactsQuery } from '@/states/contactSlice'; import { Combobox } from '@/components/ui/combobox'; import { X } from 'lucide-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import SplitBillHistoryDialog, { splitBillRowInfo } from '@/components/bills/SplitBillHistoryDialog'; interface TransactionListProps { transactions?: Transaction[]; @@ -182,6 +183,19 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader }; const getTransactionDisplayInfo = (transaction: Transaction) => { + const split = splitBillRowInfo(transaction); + if (split) { + return { + amount: split.amount, + counterpartyName: split.title, + counterpartyEmail: split.subtitle, + counterpartyProfileImage: undefined as string | undefined, + counterpartyType: 'group' as const, + isOutgoing: false, + split, + }; + } + // Determine if it's outgoing based on sender ownership matching current identity const isOutgoing = transaction.senderWallet?.userId === currentUserId || @@ -224,7 +238,21 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader counterpartyName = transaction.description || (isOutgoing ? 'Money Sent' : 'Money Received'); } - return { amount, counterpartyName, counterpartyEmail, counterpartyProfileImage, counterpartyType, isOutgoing }; + return { amount, counterpartyName, counterpartyEmail, counterpartyProfileImage, counterpartyType, isOutgoing, split: null }; + }; + + // A covered share was paid from someone else's wallet: shown without +/- in a neutral color. + const amountStyle = (amount: number, split: ReturnType) => { + if (split) { + return { + sign: split.sign, + amountClass: split.sign === '' ? 'text-gray-500 dark:text-gray-400' : 'text-green-600 dark:text-green-400', + }; + } + return { + sign: amount < 0 ? '-' : '+', + amountClass: amount < 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400', + }; }; useEffect(() => { @@ -434,7 +462,8 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader {filteredTransactions.map((transaction) => { - const { amount, counterpartyName, counterpartyEmail, counterpartyProfileImage, counterpartyType, isOutgoing } = getTransactionDisplayInfo(transaction); + const { amount, counterpartyName, counterpartyEmail, counterpartyProfileImage, counterpartyType, isOutgoing, split } = getTransactionDisplayInfo(transaction); + const { sign, amountClass } = amountStyle(amount, split); const isSelected = selectedTransactions.has(transaction.id); const sourceLabel = transaction.senderSubActionId ? 'Sub-Action Wallet' : 'Wallet'; const destinationLabel = (transaction.resolvedReceiverWalletId || transaction.receiverWalletId) ? 'Wallet' : 'Account'; @@ -463,10 +492,10 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader - {isOutgoing ? 'Payment Sent' : 'Payment Received'} + {split ? (split.sign === '' ? 'Covered for you' : 'Split payment') : isOutgoing ? 'Payment Sent' : 'Payment Received'} - - {amount < 0 ? '-' : '+'}RWF {isNaN(Math.abs(amount)) ? '0' : Math.abs(amount).toLocaleString()} + + {sign}RWF {isNaN(Math.abs(amount)) ? '0' : Math.abs(amount).toLocaleString()} @@ -491,20 +520,24 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader > View - - + {!split && ( + <> + + + + )} ); @@ -531,7 +564,8 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader Select all {filteredTransactions.map((transaction) => { - const { amount, counterpartyName, counterpartyEmail, counterpartyProfileImage, counterpartyType, isOutgoing } = getTransactionDisplayInfo(transaction); + const { amount, counterpartyName, counterpartyEmail, counterpartyProfileImage, counterpartyType, isOutgoing, split } = getTransactionDisplayInfo(transaction); + const { sign, amountClass } = amountStyle(amount, split); const isSelected = selectedTransactions.has(transaction.id); return (
@@ -554,8 +588,8 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader
{counterpartyEmail}
- - {amount < 0 ? '-' : '+'}RWF {isNaN(Math.abs(amount)) ? '0' : Math.abs(amount).toLocaleString()} + + {sign}RWF {isNaN(Math.abs(amount)) ? '0' : Math.abs(amount).toLocaleString()}
@@ -566,7 +600,7 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader }`}> {transaction.status.charAt(0).toUpperCase() + transaction.status.slice(1)} - {isOutgoing ? 'Sent' : 'Received'} + {split ? (split.sign === '' ? 'Covered for you' : 'Split payment') : isOutgoing ? 'Sent' : 'Received'} {new Date(transaction.createdAt).toLocaleDateString('en-US', { month: 'short', @@ -581,7 +615,7 @@ const TransactionListInner = ({ transactions: propTransactions, toolbarInHeader > View -
+
- !isOpen && setOpenTransactionId(null)}> - {selectedTransaction && selectedTransactionDisplay && ( + {selectedTransaction && selectedTransactionDisplay?.split && ( + setOpenTransactionId(null)} /> + )} + !isOpen && setOpenTransactionId(null)} + > + {selectedTransaction && selectedTransactionDisplay && !selectedTransactionDisplay.split && ( Transaction Details diff --git a/components/RecentTransactions.tsx b/components/RecentTransactions.tsx index fe51683..7bce73f 100644 --- a/components/RecentTransactions.tsx +++ b/components/RecentTransactions.tsx @@ -8,6 +8,7 @@ import { getUserIdFromToken, isTokenExpired } from '@/utils/jwtUtils'; import { CheckCircle, Clock, AlertCircle, RefreshCcw } from 'lucide-react'; import { UserAvatar } from '@/components/UserAvatar'; import { TransactionDetailsModal } from '@/components/TransactionDetailsModal'; +import SplitBillHistoryDialog, { splitBillRowInfo } from '@/components/bills/SplitBillHistoryDialog'; export const RecentTransactions: React.FC = () => { const [transactions, setTransactions] = useState([]); @@ -91,6 +92,20 @@ export const RecentTransactions: React.FC = () => { }; const getTransactionDisplayInfo = (transaction: Transaction) => { + const split = splitBillRowInfo(transaction); + if (split) { + return { + amount: split.amount, + counterpartyName: split.title, + transactionType: 'Split', + isOutgoing: false, + isToOrganization: false, + status: transaction.status || 'completed', + counterpartyProfileImage: undefined as string | undefined, + split, + }; + } + // Determine if it's outgoing based on sender ownership matching current identity const isOutgoing = transaction.senderWallet?.userId === currentUserId || @@ -137,7 +152,7 @@ export const RecentTransactions: React.FC = () => { const status = transaction.status || 'completed'; // 'completed', 'pending', 'failed' const transactionType = isOutgoing ? 'Sent' : 'Received'; - return { amount, counterpartyName, transactionType, isOutgoing, isToOrganization, status, counterpartyProfileImage }; + return { amount, counterpartyName, transactionType, isOutgoing, isToOrganization, status, counterpartyProfileImage, split: null }; }; if (loading) { @@ -200,7 +215,11 @@ export const RecentTransactions: React.FC = () => { {/* Mobile & Desktop List View */}
{displayedTransactions.map((transaction) => { - const { amount, counterpartyName, transactionType, isOutgoing, isToOrganization, status, counterpartyProfileImage } = getTransactionDisplayInfo(transaction); + const { amount, counterpartyName, transactionType, isOutgoing, isToOrganization, status, counterpartyProfileImage, split } = getTransactionDisplayInfo(transaction); + const sign = split ? split.sign : isOutgoing ? '-' : '+'; + const amountColor = split?.sign === '' + ? 'text-gray-500 dark:text-gray-400' + : isOutgoing ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'; // Determine circle color based on transaction type const getCircleColor = () => { @@ -262,10 +281,10 @@ export const RecentTransactions: React.FC = () => { {/* Second line: Amount */}
- - {isOutgoing ? '-' : '+'} RWF {isNaN(Math.abs(amount)) ? '0' : Math.abs(amount).toLocaleString()} + + {sign} RWF {isNaN(Math.abs(amount)) ? '0' : Math.abs(amount).toLocaleString()} - + }
@@ -293,7 +312,10 @@ export const RecentTransactions: React.FC = () => {
{/* Transaction Details Modal */} - {selectedTransaction && ( + {selectedTransaction && splitBillRowInfo(selectedTransaction) && ( + setSelectedTransaction(null)} /> + )} + {selectedTransaction && !splitBillRowInfo(selectedTransaction) && ( = ({ walletId, entity const [tx, setTx] = useState([]); const [loading, setLoading] = useState(true); const [downloading, setDownloading] = useState(null); + const [openSplit, setOpenSplit] = useState(null); const load = useCallback(async () => { setLoading(true); @@ -86,6 +88,37 @@ export const RecentActivity: React.FC = ({ walletId, entity ) : (
{tx.map((t) => { + const split = splitBillRowInfo(t); + if (split) { + const covered = split.sign === ''; + return ( + + ); + } const outgoing = t.senderWalletId === walletId; const other = outgoing ? t.receiverWallet : t.senderWallet; const cur = t.currency || currency; @@ -130,6 +163,8 @@ export const RecentActivity: React.FC = ({ walletId, entity })}
)} + + {openSplit && setOpenSplit(null)} />} ); }; diff --git a/components/bills/BillDetail.tsx b/components/bills/BillDetail.tsx index 8515196..9cd6d75 100644 --- a/components/bills/BillDetail.tsx +++ b/components/bills/BillDetail.tsx @@ -37,6 +37,7 @@ import { import socketService from "@/services/socketService" import { useAccent } from "@/hooks/use-accent" import SharePinDialog from "@/components/bills/SharePinDialog" +import FindPersonByLink, { FoundPerson } from "@/components/bills/FindPersonByLink" interface BillShare { id: string @@ -157,6 +158,22 @@ export default function BillDetail({ billId }: { billId: string }) { })) .filter((c: any) => !existingPayerIds.has(c.id) && !(bill.recipient?.type === "user" && bill.recipient.id === c.id)) + const reassignTo = async (person: { id: string; name: string }) => { + if (!reassignTarget) return + await takeAction(`Reassigned to ${person.name}`, () => + reassignShare({ billId, shareId: reassignTarget.id, newPayerId: person.id }).unwrap() + ) + setReassignTarget(null) + } + + const validateReassign = (person: FoundPerson) => { + if (existingPayerIds.has(person.id)) return `${person.name} already has a share in this split.` + if (bill.recipient?.type === "user" && bill.recipient.id === person.id) { + return `${person.name} is receiving this payment, so they can't pay a share.` + } + return null + } + return ( <>
@@ -408,13 +425,7 @@ export default function BillDetail({ billId }: { billId: string }) {
)} +
+

Or give it to someone who isn't a contact:

+ +
diff --git a/components/bills/FindPersonByLink.tsx b/components/bills/FindPersonByLink.tsx new file mode 100644 index 0000000..056ff1d --- /dev/null +++ b/components/bills/FindPersonByLink.tsx @@ -0,0 +1,208 @@ +"use client" + +import React, { useCallback, useEffect, useRef, useState } from "react" +import { Camera, ImageUp, Loader2, X } from "lucide-react" +import { Input } from "@/components/ui/input" +import { getUserById, getOrganizationById } from "@/helpers/api" + +export interface FoundPerson { + id: string + name: string + avatar?: string +} + +interface FindPersonByLinkProps { + /** Called with the person behind a pasted profile link or a scanned profile QR. */ + onFound: (person: FoundPerson) => void + /** Return an error message to reject a person (yourself, the recipient, already added...). */ + validate?: (person: FoundPerson) => string | null + disabled?: boolean +} + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + +/** Pull a user id out of a profile link, full URL, or raw id. Profile QR codes encode the same link. */ +const extractUserId = (input: string): string | null => { + const trimmed = input.trim() + const welcomeMatch = trimmed.match(/\/welcome\/([^/?#]+)/i) + if (welcomeMatch) return welcomeMatch[1] + const uuid = trimmed.match(UUID_RE) + return uuid ? uuid[0] : null +} + +const dashedButton = + "inline-flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-gray-300 dark:border-darkBorder-light text-gray-600 dark:text-gray-300 text-sm font-semibold hover:border-brand-green hover:text-brand-green dark:hover:border-brand-gold dark:hover:text-brand-gold transition-colors disabled:opacity-40" + +/** + * Add someone who isn't a contact, the same way the rest of the app does: their profile link + * (pasted) or their profile QR (camera or a photo of it). + */ +export default function FindPersonByLink({ onFound, validate, disabled }: FindPersonByLinkProps) { + const [linkInput, setLinkInput] = useState("") + const [resolving, setResolving] = useState(false) + const [error, setError] = useState(null) + const [cameraActive, setCameraActive] = useState(false) + const videoRef = useRef(null) + const scannerRef = useRef(null) + const qrFileRef = useRef(null) + + const stopCamera = useCallback(() => { + if (scannerRef.current) { + try { scannerRef.current.stop(); scannerRef.current.destroy() } catch { /* already gone */ } + scannerRef.current = null + } + setCameraActive(false) + }, []) + useEffect(() => () => stopCamera(), [stopCamera]) + + // A /welcome/:id link can point at a person or an organization; only a person can owe a share. + const resolve = async (raw: string) => { + const id = extractUserId(raw) + setError(null) + if (!id) { + setError("Could not find a profile link or ID in that text.") + return + } + setResolving(true) + try { + try { + const res = await getUserById(id) + const user = res.data?.data ?? res.data + if (user?.id) { + const person: FoundPerson = { + id: user.id, + name: `${user.firstName || ""} ${user.lastName || ""}`.trim() || user.email || "User", + avatar: user.profile?.profileImage, + } + const rejection = validate?.(person) + if (rejection) { + setError(rejection) + return + } + onFound(person) + setLinkInput("") + return + } + } catch { /* fall through to the organization check */ } + + try { + const orgRes = await getOrganizationById(id) + const org = orgRes.data?.data ?? orgRes.data + if (org?.id) { + setError("That link is an organization. Only people can pay a share.") + return + } + } catch { /* neither user nor org */ } + + setError("No account found for that link or QR code.") + } finally { + setResolving(false) + } + } + + const startCamera = async () => { + setError(null) + try { + const QrScanner = (await import("qr-scanner")).default + if (!(await QrScanner.hasCamera())) { + setError("No camera was found on this device.") + return + } + setCameraActive(true) + // Let the