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/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..ed604a4 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

+
@@ -236,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 804fe2d..37efd08 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; } @@ -402,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."} @@ -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 (

@@ -464,7 +479,7 @@ const AmountPageInner = () => { {" "}is requesting payment

{requestMeta.note && ( -

"{requestMeta.note}"

+

“{requestMeta.note}”

)}
@@ -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/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 new file mode 100644 index 0000000..9cd6d75 --- /dev/null +++ b/components/bills/BillDetail.tsx @@ -0,0 +1,497 @@ +"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" +import FindPersonByLink, { FoundPerson } from "@/components/bills/FindPersonByLink" + +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)) + + 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 ( + <> +
+
+ +
+
+

{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) => ( + + ))} +
+ )} +
+

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

+ +
+
+
+ + {/* 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/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