283 lines
8.3 KiB
TypeScript
283 lines
8.3 KiB
TypeScript
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
FlatList,
|
|
ListRenderItem,
|
|
} from "react-native";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { api } from "@/lib/api";
|
|
import {
|
|
ScanLine,
|
|
CheckCircle2,
|
|
Wallet,
|
|
ChevronRight,
|
|
AlertTriangle,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { toast } from "@/lib/toast-store";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
|
|
const PRIMARY = "#ea580c";
|
|
|
|
interface Payment {
|
|
id: string;
|
|
transactionId: string;
|
|
amount:
|
|
| {
|
|
value: number;
|
|
currency: string;
|
|
}
|
|
| number;
|
|
currency: string;
|
|
paymentDate: string;
|
|
paymentMethod: string;
|
|
notes: string;
|
|
isFlagged: boolean;
|
|
flagReason: string;
|
|
flagNotes: string;
|
|
receiptPath: string;
|
|
userId: string;
|
|
invoiceId: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export default function PaymentsScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const [payments, setPayments] = useState<Payment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
const fetchPayments = useCallback(
|
|
async (pageNum: number, isRefresh = false) => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
|
|
try {
|
|
if (!isRefresh) {
|
|
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
|
}
|
|
|
|
const response = await api.payments.getAll({
|
|
query: { page: pageNum, limit: 10 },
|
|
});
|
|
|
|
const newPayments = response.data;
|
|
if (isRefresh) {
|
|
setPayments(newPayments);
|
|
} else {
|
|
setPayments((prev) =>
|
|
pageNum === 1 ? newPayments : [...prev, ...newPayments],
|
|
);
|
|
}
|
|
|
|
setHasMore(response.meta.hasNextPage);
|
|
setPage(pageNum);
|
|
} catch (err: any) {
|
|
console.error("[Payments] Fetch error:", err);
|
|
toast.error("Error", "Failed to fetch payments.");
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchPayments(1);
|
|
}, [fetchPayments]);
|
|
|
|
const onRefresh = () => {
|
|
setRefreshing(true);
|
|
fetchPayments(1, true);
|
|
};
|
|
|
|
const loadMore = () => {
|
|
if (hasMore && !loadingMore && !loading) {
|
|
fetchPayments(page + 1);
|
|
}
|
|
};
|
|
|
|
const categorized = {
|
|
flagged: payments.filter((p) => p.isFlagged),
|
|
pending: payments.filter((p) => !p.invoiceId && !p.isFlagged),
|
|
reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged),
|
|
};
|
|
|
|
const renderPaymentItem = (
|
|
pay: Payment,
|
|
type: "reconciled" | "pending" | "flagged",
|
|
) => {
|
|
const isReconciled = type === "reconciled";
|
|
const isFlagged = type === "flagged";
|
|
|
|
// Support both object and direct number amount from API
|
|
const amountValue =
|
|
typeof pay.amount === "object" ? pay.amount.value : pay.amount;
|
|
const dateStr = new Date(pay.paymentDate).toLocaleDateString();
|
|
|
|
return (
|
|
<Pressable
|
|
key={pay.id}
|
|
onPress={() => nav.go("payments/[id]", { id: pay.id })}
|
|
className="mb-2"
|
|
>
|
|
<Card
|
|
className={`rounded-[10px] bg-card overflow-hidden ${isReconciled ? "opacity-80" : ""}`}
|
|
>
|
|
<View className="flex-row items-center p-3">
|
|
<View
|
|
className={`mr-2 rounded-[6px] p-2 border ${
|
|
isFlagged
|
|
? "bg-red-500/10 border-red-500/5"
|
|
: isReconciled
|
|
? "bg-emerald-500/10 border-emerald-500/5"
|
|
: "bg-primary/10 border-primary/5"
|
|
}`}
|
|
>
|
|
{isFlagged ? (
|
|
<AlertTriangle color="#ef4444" size={18} strokeWidth={2.5} />
|
|
) : isReconciled ? (
|
|
<CheckCircle2 color="#10b981" size={18} strokeWidth={2.5} />
|
|
) : (
|
|
<Wallet color={PRIMARY} size={18} strokeWidth={2.5} />
|
|
)}
|
|
</View>
|
|
<View className="flex-1">
|
|
<Text variant="p" className="text-foreground font-bold">
|
|
{pay.currency || "$"}
|
|
{amountValue?.toLocaleString()}
|
|
</Text>
|
|
<Text variant="muted" className="text-xs">
|
|
{pay.paymentMethod} · {dateStr}
|
|
</Text>
|
|
</View>
|
|
{isFlagged ? (
|
|
<View className="bg-red-500/10 px-3 py-1 rounded-[6px]">
|
|
<Text className="text-red-700 text-[10px] font-semibold">
|
|
Flagged
|
|
</Text>
|
|
</View>
|
|
) : !isReconciled ? (
|
|
<View className="bg-amber-500/10 px-4 py-2 rounded-[6px]">
|
|
<Text className="text-amber-700 text-[10px] font-semibold">
|
|
Match
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<ChevronRight size={18} strokeWidth={2} color="#000" />
|
|
)}
|
|
</View>
|
|
</Card>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
if (loading && page === 1) {
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<StandardHeader />
|
|
<View className="flex-1 items-center justify-center">
|
|
<ActivityIndicator size="large" color={PRIMARY} />
|
|
</View>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<StandardHeader />
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
|
showsVerticalScrollIndicator={false}
|
|
onScroll={({ nativeEvent }) => {
|
|
const isCloseToBottom =
|
|
nativeEvent.layoutMeasurement.height +
|
|
nativeEvent.contentOffset.y >=
|
|
nativeEvent.contentSize.height - 20;
|
|
if (isCloseToBottom) loadMore();
|
|
}}
|
|
scrollEventThrottle={400}
|
|
>
|
|
<Button
|
|
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
|
onPress={() => nav.go("sms-scan")}
|
|
>
|
|
<ScanLine color="#ffffff" size={18} strokeWidth={2.5} />
|
|
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
|
Scan SMS
|
|
</Text>
|
|
</Button>
|
|
|
|
{/* Flagged Section */}
|
|
{categorized.flagged.length > 0 && (
|
|
<>
|
|
<View className="mb-4 flex-row items-center gap-3">
|
|
<Text variant="h4" className="text-red-600">
|
|
Flagged Payments
|
|
</Text>
|
|
</View>
|
|
<View className="gap-2 mb-6">
|
|
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
|
|
</View>
|
|
</>
|
|
)}
|
|
|
|
{/* Pending Section */}
|
|
<View className="mb-4 flex-row items-center gap-3">
|
|
<Text variant="h4" className="text-foreground">
|
|
Pending Match
|
|
</Text>
|
|
</View>
|
|
<View className="gap-2 mb-6">
|
|
{categorized.pending.length > 0 ? (
|
|
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
|
) : (
|
|
<Text variant="muted" className="text-center py-4">
|
|
No pending matches.
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Reconciled Section */}
|
|
<View className="mb-4 flex-row items-center gap-3">
|
|
<Text variant="h4" className="text-foreground">
|
|
Reconciled
|
|
</Text>
|
|
</View>
|
|
<View className="gap-2">
|
|
{categorized.reconciled.length > 0 ? (
|
|
categorized.reconciled.map((p) =>
|
|
renderPaymentItem(p, "reconciled"),
|
|
)
|
|
) : (
|
|
<Text variant="muted" className="text-center py-4">
|
|
No reconciled payments.
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{loadingMore && (
|
|
<View className="py-4">
|
|
<ActivityIndicator color={PRIMARY} />
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|