Yaltopia-Tickets-App/app/(tabs)/payments.tsx
2026-03-11 22:48:53 +03:00

303 lines
9.2 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,
Plus,
} 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";
import { EmptyState } from "@/components/EmptyState";
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
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 permissions = useAuthStore((s) => s.permissions);
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);
// Check permissions
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
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">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
nativeEvent.layoutMeasurement.height +
nativeEvent.contentOffset.y >=
nativeEvent.contentSize.height - 20;
if (isCloseToBottom) loadMore();
}}
scrollEventThrottle={400}
>
<StandardHeader />
<View className="px-[16px] pt-6">
<Button
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
onPress={() => nav.go("payment-requests/create")}
>
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
Create Payment Request
</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"))
) : (
<View className="py-1">
<EmptyState
title="No pending payments"
description="Payments that haven't been matched to invoices yet will appear here."
hint="Upload receipts or scan SMS to add payments."
previewLines={3}
/>
</View>
)}
</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"),
)
) : (
<View className="py-4">
<EmptyState
title="No reconciled payments"
description="Payments matched to invoices will show up here once reconciled."
hint="Match pending payments to invoices for reconciliation."
previewLines={3}
/>
</View>
)}
</View>
{loadingMore && (
<View className="py-4">
<ActivityIndicator color={PRIMARY} />
</View>
)}
</View>
</ScrollView>
</ScreenWrapper>
);
}