Yaltopia-Tickets-App/app/payments/[id].tsx

477 lines
16 KiB
TypeScript

import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Wallet,
Link2,
Clock,
AlertTriangle,
User,
ShieldCheck,
Building2,
Hash,
CheckCircle2,
Eye,
Trash2,
Network,
AlertCircle,
Info,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { toast } from "@/lib/toast-store";
export default function PaymentDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [payment, setPayment] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [matching, setMatching] = useState(false);
const paymentId = Array.isArray(id) ? id[0] : id;
useEffect(() => {
const fetchPayment = async () => {
try {
setLoading(true);
if (!paymentId) throw new Error("No ID provided");
console.log("[PaymentDetail] Fetching ID:", paymentId);
const response = await api.payments.getById({
params: { id: paymentId },
});
setPayment(response);
console.log("[PaymentDetail] Response:", response);
} catch (error) {
console.error("[PaymentDetail] Error fetching payment:", error);
toast.error("Error", "Failed to fetch payment details.");
} finally {
setLoading(false);
}
};
fetchPayment();
}, [paymentId]);
const handleDelete = async () => {
Alert.alert(
"Delete Payment",
"Are you sure you want to delete this payment record?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
setDeleting(true);
try {
if (!paymentId) return;
await api.payments.delete({ params: { id: paymentId } });
toast.success("Deleted", "Payment record has been removed.");
nav.back();
} catch (err: any) {
toast.error("Error", err.message || "Failed to delete payment.");
} finally {
setDeleting(false);
}
},
},
],
);
};
const handleMatch = async () => {
if (!payment || matching || !paymentId) return;
setMatching(true);
toast.info("Matching...", "Searching for a corresponding invoice.");
try {
// 1. Fetch all invoices
const invoices = await api.invoices.getAll();
const invoiceList = Array.isArray(invoices)
? invoices
: (invoices as any).data || [];
// 2. Algorithm: Match Amount AND (Sender OR Receiver Name)
const pAmount = Number(payment.amount);
const pSender = (payment.senderName || "").toLowerCase().trim();
const pReceiver = (payment.receiverName || "").toLowerCase().trim();
const match = invoiceList.find((inv: any) => {
const invAmount = Number(inv.amount);
const invCustomer = (inv.customerName || "").toLowerCase().trim();
// Exact amount match is primary
const amountMatches = Math.abs(invAmount - pAmount) < 0.01;
// Name proximity match (either sender or receiver)
const nameMatches =
(invCustomer && pSender && pSender.includes(invCustomer)) ||
(invCustomer && pSender && invCustomer.includes(pSender)) ||
(invCustomer && pReceiver && pReceiver.includes(invCustomer)) ||
(invCustomer && pReceiver && invCustomer.includes(pReceiver));
return amountMatches && nameMatches;
});
if (!match) {
toast.info(
"No Match Found",
"Could not find an invoice with the same amount and customer name.",
);
return;
}
// 3. Confirm match with user
Alert.alert(
"Match Found!",
`Associate this payment with Invoice #${match.invoiceNumber} for ${match.customerName}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Associate",
style: "default",
onPress: async () => {
try {
await api.payments.associate({
params: { id: paymentId },
body: { invoiceId: match.id },
});
toast.success(
"Success",
"Payment successfully associated with invoice.",
);
// Refresh data
const updated = await api.payments.getById({
params: { id: paymentId },
});
setPayment(updated);
} catch (err: any) {
toast.error("Error", err.message || "Failed to associate.");
}
},
},
],
);
} catch (err: any) {
console.error("[Match] Error:", err);
toast.error("Error", "Failed to fetch invoices for matching.");
} finally {
setMatching(false);
}
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Payment Details" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
<Text
variant="muted"
className="mt-4 font-bold uppercase tracking-widest text-[10px]"
>
Retrieving Transaction...
</Text>
</View>
</ScreenWrapper>
);
}
if (!payment) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Payment Details" showBack />
<View className="flex-1 justify-center items-center p-6">
<AlertTriangle color="#ef4444" size={48} strokeWidth={1.5} />
<Text variant="h4" className="mt-4 text-foreground font-black">
Transaction Not Found
</Text>
<Text variant="muted" className="mt-2 text-center">
The requested payment record could not be retrieved from the server.
</Text>
</View>
</ScreenWrapper>
);
}
const amountValue = Number(
typeof payment.amount === "object" ? payment.amount.value : payment.amount,
);
const paymentDate = payment.paymentDate
? new Date(payment.paymentDate)
: new Date(payment.createdAt);
const isFlagged = payment.isFlagged === true;
const isScanned = payment.isScanned === true;
const scanned = payment.scannedData || {};
const extracted = scanned.extractedFields || {};
const verification = payment.verification || scanned.verification || {};
const isFailed =
verification.verificationStatus === "failed" ||
verification.isVerified === false;
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title={"Payment Details"} showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 10 }}
showsVerticalScrollIndicator={false}
>
{/* Urgent Alerts */}
{isFlagged && (
<View className="mx-5 my-4 bg-red-500/10 border border-red-500/20 rounded-[24px] p-5 flex-row items-start">
<View className="bg-red-500/20 p-2 rounded-full mr-4">
<AlertTriangle color="#ef4444" size={20} />
</View>
<View className="flex-1">
<Text className="text-red-600 font-black text-[10px] uppercase tracking-[2px] mb-1">
Security Flag ({payment.flagReason || "Audit Needed"})
</Text>
<Text className="text-foreground/80 font-medium text-xs leading-5">
{payment.flagNotes || "System flagged this for manual review."}
</Text>
</View>
</View>
)}
{/* Hero Section */}
<View className="px-5 pt-2">
{/* Status Badges */}
<View className="flex-row flex-wrap gap-2 mb-6">
<View
className={`px-3 py-1 rounded-full flex-row items-center gap-2 ${payment.invoiceId ? "bg-emerald-500/10" : "bg-amber-500/10"}`}
>
<View
className={`w-2 h-2 rounded-full ${payment.invoiceId ? "bg-emerald-500" : "bg-amber-500"}`}
/>
<Text
className={`text-[10px] font-black uppercase tracking-widest ${payment.invoiceId ? "text-emerald-600" : "text-amber-600"}`}
>
{payment.invoiceId ? "Matched" : "Pending Match"}
</Text>
</View>
{isFailed && (
<View className="bg-red-500/10 px-3 py-1 rounded-full flex-row items-center gap-2">
<AlertCircle size={12} color="#ef4444" />
<Text className="text-red-600 text-[10px] font-black uppercase tracking-widest">
Verify Failed
</Text>
</View>
)}
{isScanned && (
<View className="bg-primary/10 px-3 py-1 rounded-full flex-row items-center gap-2 border border-primary/20">
<CheckCircle2 size={12} color="#ea580c" />
<Text className="text-primary text-[10px] font-black uppercase tracking-widest">
Scanned
</Text>
</View>
)}
</View>
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
>
Total Transaction Amount
</Text>
<View className="flex-row items-end gap-3 mb-8">
<Text
variant="h1"
className="text-4xl font-black text-foreground tracking-tighter"
>
{amountValue.toLocaleString()}
</Text>
<Text className="text-2xl font-black text-primary mb-1">
{payment.currency || "USD"}
</Text>
</View>
{/* Core Info Grid */}
<View className="flex-row gap-3 mb-6">
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
<Building2 size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
>
Merchant
</Text>
<Text
className="text-foreground font-black text-sm"
numberOfLines={1}
>
{extracted.merchantName ||
payment.merchantName ||
"Unknown Merchant"}
</Text>
</Card>
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
<Network size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
>
Provider
</Text>
<Text
className="text-foreground font-black text-sm"
numberOfLines={1}
>
{extracted.provider ||
payment.paymentMethod ||
"Direct Payment"}
</Text>
</Card>
</View>
</View>
{/* Sender / Payer Box */}
<View className="px-5 mb-8">
<View className="bg-card/50 rounded-[6px] p-6 border border-border/40 shadow-sm shadow-black/5">
<View className="flex-row items-center gap-4 mb-5">
<View className="h-12 w-12 rounded-full bg-secondary/10 items-center justify-center border border-secondary/20">
<User color={isDark ? "#f1f5f9" : "#0f172a"} size={22} />
</View>
<View>
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-[2px] mb-0.5"
>
Transaction Origin
</Text>
<Text className="text-foreground font-black text-lg">
{payment.senderName ||
(payment.user
? `${payment.user.firstName} ${payment.user.lastName}`
: "Business Account")}
</Text>
</View>
</View>
<View className="flex-row flex-wrap gap-5 pt-5 border-t border-border/40">
<View className="flex-row items-center gap-2">
<Hash size={14} color="#64748b" />
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
{payment.transactionId || "INTERNAL-TXN"}
</Text>
</View>
<View className="flex-row items-center gap-2">
<Clock size={14} color="#64748b" />
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
{paymentDate.toLocaleString()}
</Text>
</View>
</View>
</View>
</View>
{/* Notes */}
{payment.notes && (
<View className="px-5 mb-10">
<View className="flex-row items-center gap-2 mb-3">
<Info size={14} color="#64748b" />
<Text
variant="muted"
className="text-[10px] uppercase font-black tracking-widest"
>
Transaction Notes
</Text>
</View>
<View className="bg-muted/5 border border-border/40 rounded-[6px] p-5">
<Text className="text-foreground font-medium italic opacity-70 leading-6">
" {payment.notes} "
</Text>
</View>
</View>
)}
{/* Premium Actions */}
<View className="px-5 gap-3">
<View className="flex-row gap-3">
{scanned?.imageUrl && (
<Button
className="flex-1 h-14 rounded-[6px] bg-secondary shadow-lg shadow-black/10"
onPress={() =>
Linking.openURL(
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
)
}
>
<Eye
color={isDark ? "#f1f5f9" : "#0f172a"}
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
View Receipt
</Text>
</Button>
)}
{!payment.invoiceId && !isFailed && (
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={handleMatch}
disabled={matching}
>
{matching ? (
<ActivityIndicator color="white" />
) : (
<>
<Link2 size={18} color="white" strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
Match Invoice
</Text>
</>
)}
</Button>
)}
</View>
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-red-500/5 mb-10"
onPress={handleDelete}
disabled={deleting}
>
{deleting ? (
<ActivityIndicator color="#ef4444" />
) : (
<>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
Terminate Record
</Text>
</>
)}
</Button>
</View>
</ScrollView>
</ScreenWrapper>
);
}