Yaltopia-Tickets-App/app/payments/[id].tsx
2026-06-17 15:16:40 +03:00

1755 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useEffect } from "react";
import {
View,
ScrollView,
TextInput,
ActivityIndicator,
Pressable,
Linking,
Modal,
StyleSheet,
Alert,
Platform,
PermissionsAndroid,
Dimensions,
Image,
} from "react-native";
import { WebView } from "react-native-webview";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/EmptyState";
import { FormFlow } from "@/components/FormFlow";
import {
Wallet,
Link2,
Clock,
AlertTriangle,
User,
Building2,
Hash,
Eye,
Trash2,
Info,
Search,
ChevronDown,
ChevronRight,
X,
Scan,
MoreVertical,
Edit,
Package,
Share2,
Mail,
MessageSquare,
Calendar,
Check,
Camera,
ArrowUpRight,
Flag,
} 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";
import { ActionModal } from "@/components/ActionModal";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { getPlaceholderColor } from "@/lib/colors";
import ticketImage from "@/assets/ticket.png";
import { CheckCircle2, CreditCardIcon } from "lucide-react-native";
let SmsAndroid: any = null;
if (Platform.OS === "android") {
try {
const smsModule = require("react-native-get-sms-android");
SmsAndroid = smsModule.default || smsModule;
} catch (e) {
console.log("[PaymentDetail] SMS module unavailable");
}
}
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
const S = StyleSheet.create({
input: {
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 12,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
textAlignVertical: "center",
},
});
const CURRENCIES = ["ETB", "USD", "EUR", "GBP", "KES", "ZAR"];
const PAYMENT_METHODS = [
"Telebirr",
"CBE",
"Dashen",
"DECSI",
"Bank Transfer",
"Cash",
"Other",
];
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 [scanningSms, setScanningSms] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [showMoreSheet, setShowMoreSheet] = useState(false);
const [activeTab, setActiveTab] = useState<"details" | "items" | "image">(
"details",
);
const [linkedInvoice, setLinkedInvoice] = useState<any>(null);
const [showImageFullScreen, setShowImageFullScreen] = useState(false);
const [imageLoading, setImageLoading] = useState(false);
const [showFlagModal, setShowFlagModal] = useState(false);
const [flagReason, setFlagReason] = useState<"FAKE" | "SCAM" | "OTHER">(
"FAKE",
);
const [flagNotes, setFlagNotes] = useState("");
const [flagging, setFlagging] = useState(false);
// Edit form state
const [editTxnId, setEditTxnId] = useState("");
const [editAmount, setEditAmount] = useState("");
const [editCurrency, setEditCurrency] = useState("ETB");
const [editPaymentMethod, setEditPaymentMethod] = useState("Telebirr");
const [editPaymentDate, setEditPaymentDate] = useState("");
const [editNotes, setEditNotes] = useState("");
const [editStep, setEditStep] = useState(0);
const [showEditCurrency, setShowEditCurrency] = useState(false);
const [showEditMethod, setShowEditMethod] = useState(false);
const [showEditDate, setShowEditDate] = useState(false);
// Invoice list for matching
const [invoices, setInvoices] = useState<any[]>([]);
const [invoiceSearch, setInvoiceSearch] = useState("");
const paymentId = Array.isArray(id) ? id[0] : id;
function useInputColors() {
const dark = isDark;
return {
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: dark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)",
};
}
const c = useInputColors();
const fetchPayment = useCallback(async () => {
try {
setLoading(true);
if (!paymentId) throw new Error("No ID provided");
const response = await api.payments.getById({
params: { id: paymentId },
});
setPayment(response);
} catch (error) {
console.error("[PaymentDetail] Error fetching payment:", error);
toast.error("Error", "Failed to fetch payment details.");
} finally {
setLoading(false);
}
}, [paymentId]);
useFocusEffect(
useCallback(() => {
fetchPayment();
}, [fetchPayment]),
);
useEffect(() => {
const fetchLinked = async () => {
if (!payment?.invoiceId) {
setLinkedInvoice(null);
return;
}
try {
const inv = await api.invoices.getById({
params: { id: payment.invoiceId },
});
setLinkedInvoice(inv);
} catch (err) {
console.log("[PaymentDetail] could not load linked invoice", err);
setLinkedInvoice(null);
}
};
fetchLinked();
}, [payment?.invoiceId]);
const handleDelete = () => setShowDeleteModal(true);
const confirmDelete = async () => {
setDeleting(true);
try {
if (!paymentId) return;
await api.payments.delete({ params: { id: paymentId } });
toast.success("Deleted", "Payment record has been removed.");
setShowDeleteModal(false);
nav.back();
} catch (err: any) {
toast.error("Error", err.message || "Failed to delete payment.");
setShowDeleteModal(false);
} finally {
setDeleting(false);
}
};
const handleScanSms = async () => {
if (Platform.OS !== "android") {
toast.error(
"Not Supported",
"SMS scanning is only available on Android.",
);
return;
}
setScanningSms(true);
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS,
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
toast.error(
"Permission Denied",
"SMS access is required to scan payment messages.",
);
setScanningSms(false);
return;
}
if (!SmsAndroid) {
toast.error("Not Available", "SMS scanning module is not available.");
setScanningSms(false);
return;
}
const fiveMinsAgo = Date.now() - 5 * 60 * 1000;
const filter = { box: "inbox", minDate: fiveMinsAgo, maxCount: 30 };
SmsAndroid.list(
JSON.stringify(filter),
(fail: string) => {
toast.error("Scan Failed", fail);
setScanningSms(false);
},
(_count: number, smsList: string) => {
const messages = JSON.parse(smsList);
const match = messages.find((m: any) => {
const addr = (m.address || "").toUpperCase();
const body = (m.body || "").toUpperCase();
return (
addr === "127" ||
addr === "CBE" ||
body.includes("TELEBIRR") ||
body.includes("CBE")
);
});
if (!match) {
toast.error(
"No Match",
"No CBE or Telebirr SMS found in the last 5 minutes.",
);
setScanningSms(false);
return;
}
const text = match.body;
const lines = [
`From: ${match.address}`,
`Date: ${new Date(match.date).toLocaleString()}`,
"",
`"${text}"`,
"",
"Send this SMS to verify the payment?",
];
Alert.alert("SMS Found", lines.filter(Boolean).join("\n"), [
{
text: "Cancel",
style: "cancel",
onPress: () => setScanningSms(false),
},
{
text: "Verify",
onPress: async () => {
try {
await api.payments.verifySms({
params: { id: paymentId },
body: { smsContent: text },
});
toast.success(
"Verified",
"SMS has been sent for verification.",
);
fetchPayment();
} catch (err: any) {
toast.error("Error", err.message || "Verification failed.");
} finally {
setScanningSms(false);
}
},
},
]);
},
);
} catch (err) {
toast.error("Error", "Something went wrong during SMS scan.");
setScanningSms(false);
}
};
const openEdit = () => {
const amt = Number(
typeof payment.amount === "object"
? payment.amount.value
: payment.amount,
);
setEditTxnId(payment.transactionId || "");
setEditAmount(String(amt));
setEditCurrency(payment.currency || "ETB");
setEditPaymentMethod(payment.paymentMethod || "Telebirr");
setEditPaymentDate(
payment.paymentDate
? new Date(payment.paymentDate).toISOString().split("T")[0]
: new Date().toISOString().split("T")[0],
);
setEditNotes(payment.notes || "");
setEditStep(0);
setShowEditModal(true);
};
const handleEditSave = async () => {
if (!editAmount || parseFloat(editAmount) <= 0) {
toast.error("Validation Error", "Amount must be greater than 0");
return;
}
setEditSaving(true);
try {
await api.payments.update({
params: { id: paymentId },
body: {
transactionId: editTxnId,
amount: parseFloat(editAmount),
currency: editCurrency,
paymentDate: new Date(editPaymentDate).toISOString(),
paymentMethod: editPaymentMethod,
notes: editNotes,
},
});
toast.success("Success", "Payment updated successfully.");
setShowEditModal(false);
fetchPayment();
} catch (err: any) {
const msg =
err?.response?.data?.message ||
err?.data?.message ||
err?.message ||
"Failed to update payment";
toast.error("Error", msg);
} finally {
setEditSaving(false);
}
};
const openMatchPicker = async () => {
if (!payment || matching || !paymentId) return;
setMatching(true);
try {
const response = await api.invoices.getAll();
const list = Array.isArray(response)
? response
: (response as any).data || [];
setInvoices(list);
setInvoiceSearch("");
setShowInvoicePicker(true);
} catch (err: any) {
toast.error("Error", "Failed to fetch invoices.");
} finally {
setMatching(false);
}
};
const handleInvoiceSelect = async (inv: any) => {
setShowInvoicePicker(false);
try {
await api.payments.associate({
params: { id: paymentId },
body: { invoiceId: inv.id },
});
toast.success(
"Success",
`Payment associated with invoice #${inv.invoiceNumber || inv.id}.`,
);
fetchPayment();
} catch (err: any) {
toast.error("Error", err.message || "Failed to associate.");
}
};
const openFlagModal = () => {
if (!payment) return;
setFlagReason(payment.flagReason || "FAKE");
setFlagNotes(payment.flagNotes || "");
setShowMoreSheet(false);
setShowFlagModal(true);
};
const handleLinkInvoicePress = () => {
if (payment?.invoiceId) {
toast.warning(
"Already Linked",
"This payment is already linked to an invoice.",
);
return;
}
openMatchPicker();
};
const handleFlag = async () => {
if (!paymentId) return;
setFlagging(true);
try {
await api.payments.flag({
params: { id: paymentId },
body: { flagReason, flagNotes },
});
toast.success("Flagged", "Payment has been flagged for review.");
setShowFlagModal(false);
fetchPayment();
} catch (err: any) {
const msg =
err?.response?.data?.message ||
err?.data?.message ||
err?.message ||
"Failed to flag payment";
toast.error("Error", msg);
} finally {
setFlagging(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-sans-bold text-[10px] uppercase tracking-widest"
>
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-sans-black">
Transaction Not Found
</Text>
<Text variant="muted" className="mt-2 text-center">
The requested payment record could not be retrieved.
</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 scannedImageRaw =
payment.scannedData?.imageUrl ||
payment.scannedData?.image ||
payment.scannedData?.imagePath ||
payment.scannedData?.originalData?.imageUrl ||
payment.imageUrl ||
payment.imagePath ||
payment.receiptPath ||
null;
const scannedImageUrl = scannedImageRaw
? scannedImageRaw.startsWith("http")
? scannedImageRaw
: `${BASE_URL}${scannedImageRaw.replace(/^\//, "")}`
: null;
const hasScannedImage = Boolean(payment?.isScanned && scannedImageUrl);
const hasSms = Boolean(
payment.smsContent ||
payment.smsVerified ||
payment.smsBody ||
payment.verifiedAt ||
payment.smsId,
);
const linkedItems =
(linkedInvoice?.items?.length > 0
? linkedInvoice.items
: linkedInvoice?.scannedData?.originalData?.items) || [];
const filteredInvoices = invoices.filter((inv) => {
if (!invoiceSearch) return true;
const q = invoiceSearch.toLowerCase();
return (
(inv.invoiceNumber || "").toLowerCase().includes(q) ||
(inv.customerName || "").toLowerCase().includes(q)
);
});
const formatLongDate = (d: Date) =>
d.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
});
const ActionOption = ({
icon,
label,
description,
onPress,
destructive,
}: {
icon: React.ReactNode;
label: string;
description: string;
onPress: () => void;
destructive?: boolean;
}) => (
<Pressable
onPress={onPress}
className="flex-row items-center gap-3.5 p-4 mb-2 rounded-[6px] border border-border bg-card"
>
<View className="h-9 w-9 rounded-full bg-primary/10 items-center justify-center">
{icon}
</View>
<View className="flex-1">
<Text
className={`text-[14px] font-sans-bold ${
destructive ? "text-red-500" : "text-foreground"
}`}
>
{label}
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{description}
</Text>
</View>
</Pressable>
);
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title="Payment Details"
showBack
right={
<Pressable
onPress={() => setShowMoreSheet(true)}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<MoreVertical color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
</Pressable>
}
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
>
{/* Hero Card — illustration overflows the top */}
<View className="px-5 pt-3">
<View
className="items-center"
style={{ marginBottom: -60, zIndex: 2 }}
>
<Image
source={ticketImage}
style={{ width: 150, height: 150 }}
resizeMode="contain"
/>
</View>
<View
className="rounded-[14px] pt-14 pb-6 px-6 items-center bg-primary/5"
style={{
borderWidth: 1,
borderColor: isDark
? "rgba(255,255,255,0.06)"
: "rgba(0,0,0,0.05)",
}}
>
<Text className="text-foreground text-[34px] font-sans-black tracking-tight leading-tight">
{amountValue.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
<Text className="text-foreground text-[20px] font-sans-bold">
{payment.currency || "USD"}
</Text>
</Text>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-1">
{payment.transactionId
? `Txn ${payment.transactionId}`
: payment.paymentMethod || "Direct"}
</Text>
<View className="w-full mt-5 gap-3">
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[12px] font-sans-medium">
Method
</Text>
<View className="flex-row items-center gap-1.5">
{payment.financialInstitutionLogoUrl ? (
<Image
source={{ uri: payment.financialInstitutionLogoUrl }}
style={{ width: 16, height: 16 }}
resizeMode="contain"
/>
) : (
<Wallet size={12} color="#0f172a" strokeWidth={2.5} />
)}
<Text className="text-foreground text-[12px] font-sans-bold">
{payment.financialInstitution ||
payment.paymentMethod ||
"Direct"}
</Text>
</View>
</View>
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[12px] font-sans-medium">
Sender
</Text>
<Text
className="text-foreground text-[12px] font-sans-bold"
numberOfLines={1}
>
{payment.senderName || "Unknown"}
</Text>
</View>
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[12px] font-sans-medium">
Date
</Text>
<Text className="text-foreground text-[12px] font-sans-bold">
{formatLongDate(paymentDate)}
</Text>
</View>
{isFlagged && (
<View className="flex-row justify-between items-center">
<Text className="text-[#ef4444] text-[12px] font-sans-medium">
Flagged
</Text>
<Text className="text-[#ef4444] text-[12px] font-sans-bold">
{payment.flagReason || "Audit Needed"}
</Text>
</View>
)}
{payment.isReferenceVerified && (
<View className="flex-row justify-between items-center">
<Text className="text-emerald-600 text-[12px] font-sans-medium">
Reference Verified
</Text>
<Text className="text-emerald-600 text-[12px] font-sans-bold">
Yes
</Text>
</View>
)}
</View>
</View>
</View>
{/* Tabs */}
<View className="px-5 pt-6">
<View className="flex-row gap-6 border-b border-border">
<Pressable
onPress={() => setActiveTab("details")}
className="pb-2.5"
>
<Text
className={`text-[14px] font-sans-bold ${
activeTab === "details"
? "text-foreground"
: "text-muted-foreground"
}`}
>
Details
</Text>
{activeTab === "details" && (
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
)}
</Pressable>
{hasScannedImage && (
<Pressable
onPress={() => setActiveTab("image")}
className="pb-2.5"
>
<Text
className={`text-[14px] font-sans-bold ${
activeTab === "image"
? "text-foreground"
: "text-muted-foreground"
}`}
>
Image
</Text>
{activeTab === "image" && (
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
)}
</Pressable>
)}
</View>
</View>
{/* Tab content */}
{activeTab === "details" ? (
<View className="px-5 pt-5 gap-6">
{/* Transaction Details */}
<View>
<View className="gap-4">
<View className="flex-row items-center gap-3">
<Hash size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Transaction ID
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{payment.transactionId || "—"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<CreditCardIcon size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Payment Method
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{payment.paymentMethod || "—"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
{payment.financialInstitutionLogoUrl ? (
<Image
source={{ uri: payment.financialInstitutionLogoUrl }}
style={{ width: 15, height: 15 }}
resizeMode="contain"
/>
) : (
<Building2 size={15} color="#64748b" />
)}
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Financial Institution
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{payment.financialInstitution || "—"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<User size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Sender
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{payment.senderName || "—"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<User size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Receiver
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{payment.receiverName || "—"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<Clock size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Payment Date
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{paymentDate.toLocaleString()}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<CheckCircle2
size={15}
color={payment.isReferenceVerified ? "#16a34a" : "#94a3b8"}
/>
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Reference
</Text>
<Text
className={`font-sans-bold text-sm ${
payment.isReferenceVerified
? "text-emerald-600"
: "text-muted-foreground"
}`}
>
{payment.isReferenceVerified
? "Verified"
: "Not verified"}
</Text>
</View>
</View>
{isFlagged && (
<View className="rounded-[10px] bg-red-500/10 border border-red-500/20 p-3 gap-1.5">
<View className="flex-row items-center gap-2">
<Flag size={14} color="#ef4444" strokeWidth={2.5} />
<Text className="text-[10px] uppercase tracking-wider text-red-600 font-sans-bold">
Flagged for review
</Text>
</View>
{payment.flagReason ? (
<View className="flex-row items-baseline gap-1.5">
<Text className="text-muted-foreground text-[10px] uppercase tracking-wider font-sans-semibold">
Reason
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{payment.flagReason}
</Text>
</View>
) : null}
{payment.flagNotes ? (
<Text
className="text-foreground font-sans-medium text-[12px] leading-5"
numberOfLines={4}
>
{payment.flagNotes}
</Text>
) : null}
</View>
)}
{payment.invoiceId ? (
<Pressable
onPress={() =>
nav.go("invoices/[id]", { id: payment.invoiceId })
}
className="flex-row items-center gap-3 active:opacity-60"
>
<Link2 size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Linked Invoice
</Text>
<Text
className="text-foreground font-sans-bold text-sm underline"
numberOfLines={1}
ellipsizeMode="middle"
>
{payment.invoiceId}
</Text>
</View>
<ChevronRight size={16} color="#94a3b8" strokeWidth={2} />
</Pressable>
) : (
<View>
<Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
Linked Invoice
</Text>
<Pressable
onPress={openMatchPicker}
disabled={matching}
className="h-11 rounded-[6px] bg-primary items-center justify-center flex-row gap-2"
>
{matching ? (
<ActivityIndicator color="white" />
) : (
<>
<Link2 size={15} color="white" strokeWidth={2.5} />
<Text className="text-white text-[13px] font-sans-bold">
Link Invoice
</Text>
</>
)}
</Pressable>
</View>
)}
<View className="flex-row items-center gap-3">
<Info size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Created
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{new Date(payment.createdAt).toLocaleString()}
</Text>
</View>
</View>
</View>
</View>
{/* Note */}
{payment.notes && (
<View>
<Text className="font-sans-bold text-sm text-foreground mb-2">
Note
</Text>
<View className="rounded-[10px] bg-muted p-4">
<Text className="text-foreground font-sans-medium text-[13px] leading-5">
{payment.notes}
</Text>
</View>
</View>
)}
</View>
) : activeTab === "image" ? (
<View className="px-5 pt-5">
{hasScannedImage ? (
<View>
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
Scanned Document
</Text>
<Pressable
onPress={() => setShowImageFullScreen(true)}
className="rounded-[10px] overflow-hidden border border-border bg-card active:opacity-80"
>
<WebView
source={{ uri: scannedImageUrl || "" }}
style={{
width: "100%",
height: 360,
backgroundColor: isDark ? "#1F1F1F" : "#ffffff",
}}
originWhitelist={["*"]}
mixedContentMode="always"
scalesPageToFit
onLoadStart={() => setImageLoading(true)}
onLoadEnd={() => setImageLoading(false)}
onError={() => {
setImageLoading(false);
toast.error(
"Image Error",
"Failed to load scanned image.",
);
}}
renderError={() => (
<View className="flex-1 items-center justify-center p-4">
<Camera size={28} color="#94a3b8" />
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-2 text-center">
Failed to load image
</Text>
</View>
)}
/>
{imageLoading && (
<View
className="absolute inset-0 items-center justify-center bg-card/40"
pointerEvents="none"
>
<ActivityIndicator color="#ea580c" size="large" />
</View>
)}
<View className="absolute bottom-3 right-3 h-9 w-9 rounded-full bg-black/60 items-center justify-center flex-row">
<ArrowUpRight size={16} color="#ffffff" strokeWidth={2.5} />
</View>
</Pressable>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-2 text-center">
Tap to view full screen
</Text>
</View>
) : (
<View className="px-5 pt-12 items-center">
<View className="h-14 w-14 rounded-full bg-muted items-center justify-center mb-3">
<Camera size={22} color="#94a3b8" />
</View>
<Text className="text-foreground text-[14px] font-sans-bold mb-1">
No image available
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium text-center">
The scanned image could not be found.
</Text>
</View>
)}
</View>
) : (
<View className="px-5 pt-5">
{linkedItems.length > 0 ? (
<View>
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
Items
</Text>
<View>
{linkedItems.map((item: any, idx: number) => {
const qty = Number(
item.quantity?.value || item.quantity || 1,
);
const unitPrice = Number(
item.unitPrice?.value || item.unitPrice || 0,
);
const lineTotal = Number(
item.total?.value || item.total || qty * unitPrice,
);
return (
<View
key={idx}
className={`flex-row items-center gap-3 py-3 ${
idx < linkedItems.length - 1
? "border-b border-border"
: ""
}`}
>
<View className="h-12 w-12 rounded-[8px] bg-muted items-center justify-center overflow-hidden">
<Package
size={20}
color="#94a3b8"
strokeWidth={1.5}
/>
</View>
<View className="flex-1">
<Text
className="text-foreground text-[14px] font-sans-bold"
numberOfLines={1}
>
{item.description || `Item ${idx + 1}`}
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{qty} ×{" "}
{unitPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{payment.currency || "USD"}
</Text>
</View>
<Text className="text-foreground text-[14px] font-sans-bold">
{lineTotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{payment.currency || "USD"}
</Text>
</View>
);
})}
</View>
</View>
) : payment.invoiceId ? (
<View className="px-5 pt-12 items-center">
<ActivityIndicator color="#ea580c" size="small" />
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-3">
Loading items from linked invoice...
</Text>
</View>
) : (
<View className="px-5 pt-12 items-center">
<View className="h-14 w-14 rounded-full bg-muted items-center justify-center mb-3">
<Package size={22} color="#94a3b8" />
</View>
<Text className="text-foreground text-[14px] font-sans-bold mb-1">
No items linked
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium text-center mb-4">
Link an invoice to see its items here.
</Text>
{!payment.invoiceId && (
<Button
onPress={openMatchPicker}
className="h-10 px-5 rounded-[6px] bg-primary"
disabled={matching}
>
{matching ? (
<ActivityIndicator color="white" />
) : (
<>
<Link2 size={16} color="white" strokeWidth={2.5} />
<Text className="ml-2 text-white font-sans-bold text-xs">
Link Invoice
</Text>
</>
)}
</Button>
)}
</View>
)}
</View>
)}
{/* Action buttons */}
<View className="px-5 mt-6 gap-3">
{payment.receiptPath && (
<Pressable
onPress={() =>
Linking.openURL(
`${BASE_URL}${payment.receiptPath.replace(/^\//, "")}`,
)
}
className="h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
>
<Eye
color={isDark ? "#f1f5f9" : "#0f172a"}
size={16}
strokeWidth={2.5}
/>
<Text className="text-foreground text-[13px] font-sans-bold">
View Receipt
</Text>
</Pressable>
)}
</View>
</ScrollView>
{/* Sticky bottom bar — Scan SMS + Link Invoice side by side (like invoice detail) */}
<View
className="flex-row gap-3 px-5 py-3 border-t border-border"
style={{ backgroundColor: isDark ? "#0a0505" : "#ffffff" }}
>
<Pressable
onPress={hasSms ? undefined : handleScanSms}
disabled={scanningSms || hasSms}
className={`flex-1 h-12 rounded-[8px] border items-center justify-center flex-row gap-2 ${
hasSms ? "border-border bg-muted" : "border-border bg-card"
}`}
>
{scanningSms ? (
<ActivityIndicator color="#ea580c" />
) : (
<>
<Scan
color={hasSms ? "#94a3b8" : "#ea580c"}
size={16}
strokeWidth={2.5}
/>
<Text
className={`text-[13px] font-sans-bold ${
hasSms ? "text-muted-foreground" : "text-foreground"
}`}
>
{hasSms ? "SMS Verified" : "Scan SMS"}
</Text>
</>
)}
</Pressable>
<Pressable
onPress={handleLinkInvoicePress}
disabled={matching}
className={`flex-1 h-12 rounded-[8px] items-center justify-center flex-row gap-2 ${
payment.invoiceId
? "bg-muted border border-border"
: "bg-primary shadow-lg shadow-primary/20"
}`}
>
{matching ? (
<ActivityIndicator color="white" />
) : (
<>
<Link2
size={16}
color={payment.invoiceId ? "#94a3b8" : "#ffffff"}
strokeWidth={2.5}
/>
<Text
className={`text-[13px] font-sans-bold ${
payment.invoiceId ? "text-muted-foreground" : "text-white"
}`}
>
{payment.invoiceId ? "Linked" : "Link Invoice"}
</Text>
</>
)}
</Pressable>
</View>
{/* More bottom sheet */}
<Modal
visible={showMoreSheet}
transparent
animationType="slide"
onRequestClose={() => setShowMoreSheet(false)}
>
<Pressable
className="flex-1 bg-black/40"
onPress={() => setShowMoreSheet(false)}
>
<View className="flex-1 justify-end">
<Pressable
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
style={{ maxHeight: SCREEN_HEIGHT * 0.5 }}
onPress={(e) => e.stopPropagation()}
>
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center">
<View className="w-10" />
<Text className="text-foreground font-sans-bold text-[18px]">
Payment
</Text>
<Pressable
onPress={() => setShowMoreSheet(false)}
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
>
<X
size={14}
color={isDark ? "#f1f5f9" : "#0f172a"}
strokeWidth={2.5}
/>
</Pressable>
</View>
<ScrollView
className="px-5"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 40 }}
>
<ActionOption
icon={<Edit color="#E46212" size={18} strokeWidth={2} />}
label="Edit Payment"
description="Update amount, method, or dates"
onPress={() => {
setShowMoreSheet(false);
openEdit();
}}
/>
<ActionOption
icon={<Flag color="#ef4444" size={18} strokeWidth={2} />}
label={isFlagged ? "Update Flag" : "Flag Payment"}
description="Mark this payment for manual review"
onPress={openFlagModal}
/>
<ActionOption
icon={<Trash2 color="#ef4444" size={18} strokeWidth={2} />}
label="Delete Payment"
description="Permanently remove this record"
onPress={() => {
setShowMoreSheet(false);
handleDelete();
}}
destructive
/>
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
{/* Edit Modal */}
<Modal
visible={showEditModal}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowEditModal(false)}
>
<ScreenWrapper className="bg-background">
<View className="flex-1 flex-col">
<View className="flex-row items-center justify-between px-4 pt-4 pb-2">
<Text variant="h4" className="text-foreground font-sans-semibold">
Edit Payment
</Text>
<Pressable
onPress={() => setShowEditModal(false)}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<X color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
</Pressable>
</View>
<FormFlow
steps={[
{ key: "details", label: "Details" },
{ key: "schedule", label: "Schedule" },
{ key: "notes", label: "Notes" },
]}
currentStep={editStep}
onNext={() => setEditStep(editStep + 1)}
onBack={() => setEditStep(editStep - 1)}
onComplete={handleEditSave}
loading={editSaving}
completeLabel="Save Changes"
hideHeader
>
{editStep === 0 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Payment Details
</Text>
<View className="bg-card rounded-[6px] gap-4">
<View>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Transaction ID
</Text>
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
},
]}
placeholder="TXN-2024-001"
placeholderTextColor={c.placeholder}
value={editTxnId}
onChangeText={setEditTxnId}
autoCorrect={false}
/>
</View>
<View className="flex-row gap-4">
<View style={{ flex: 1 }}>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Amount
</Text>
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
},
]}
placeholder="0.00"
placeholderTextColor={c.placeholder}
value={editAmount}
onChangeText={setEditAmount}
keyboardType="numeric"
/>
</View>
<View style={{ flex: 1 }}>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Currency
</Text>
<Pressable
onPress={() => setShowEditCurrency(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{
backgroundColor: c.bg,
borderColor: c.border,
}}
>
<Text
className="text-xs font-sans-bold"
style={{ color: c.text }}
>
{editCurrency}
</Text>
<ChevronDown
size={14}
color={c.text}
strokeWidth={3}
/>
</Pressable>
</View>
</View>
</View>
</View>
)}
{editStep === 1 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Schedule
</Text>
<View className="bg-card rounded-[6px] gap-4">
<View style={{ flex: 1 }}>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Payment Date
</Text>
<Pressable
onPress={() => setShowEditDate(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text
className="text-xs font-sans-medium"
style={{ color: c.text }}
>
{editPaymentDate || "Select Date"}
</Text>
<Clock size={14} color="#ea580c" strokeWidth={2.5} />
</Pressable>
</View>
<View style={{ flex: 1 }}>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Method
</Text>
<Pressable
onPress={() => setShowEditMethod(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text
className="text-xs font-sans-bold"
style={{ color: c.text }}
>
{editPaymentMethod}
</Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</Pressable>
</View>
</View>
</View>
)}
{editStep === 2 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Notes
</Text>
<View className="bg-card rounded-[6px]">
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Notes
</Text>
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
height: 120,
textAlignVertical: "top",
paddingTop: 10,
},
]}
placeholder="Optional notes"
placeholderTextColor={c.placeholder}
value={editNotes}
onChangeText={setEditNotes}
multiline
/>
</View>
</View>
)}
</FormFlow>
</View>
<PickerModal
visible={showEditCurrency}
onClose={() => setShowEditCurrency(false)}
title="Select Currency"
>
{CURRENCIES.map((curr) => (
<SelectOption
key={curr}
label={curr}
value={curr}
selected={editCurrency === curr}
onSelect={(v) => {
setEditCurrency(v);
setShowEditCurrency(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showEditMethod}
onClose={() => setShowEditMethod(false)}
title="Select Payment Method"
>
{PAYMENT_METHODS.map((method) => (
<SelectOption
key={method}
label={method}
value={method}
selected={editPaymentMethod === method}
onSelect={(v) => {
setEditPaymentMethod(v);
setShowEditMethod(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showEditDate}
onClose={() => setShowEditDate(false)}
title="Select Payment Date"
>
<CalendarGrid
selectedDate={editPaymentDate}
onSelect={(v) => {
setEditPaymentDate(v);
setShowEditDate(false);
}}
/>
</PickerModal>
</ScreenWrapper>
</Modal>
{/* Invoice Picker Modal */}
<PickerModal
visible={showInvoicePicker}
onClose={() => setShowInvoicePicker(false)}
title="Select Invoice"
>
<View className="px-4 pb-3">
<View className="flex-row items-center rounded-xl px-3 border border-border h-10">
<Search size={16} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-2 text-foreground py-0 text-sm"
placeholder="Search by number or customer..."
placeholderTextColor={getPlaceholderColor(isDark)}
value={invoiceSearch}
onChangeText={setInvoiceSearch}
autoCorrect={false}
style={{ textAlignVertical: "center" }}
/>
</View>
</View>
<ScrollView className="max-h-96" keyboardShouldPersistTaps="handled">
{filteredInvoices.length > 0 ? (
filteredInvoices.map((inv) => (
<Pressable
key={inv.id}
onPress={() => handleInvoiceSelect(inv)}
className="px-4 py-3 border-b border-border/40 flex-row items-center"
>
<View className="flex-1">
<Text className="text-foreground font-sans-bold text-sm">
{inv.customerName || "Unknown"}
</Text>
<Text className="text-muted-foreground text-[10px] font-sans-semibold uppercase tracking-wider mt-0.5">
#{inv.invoiceNumber || inv.id} · {inv.currency || "ETB"}{" "}
{Number(inv.amount || 0).toLocaleString()}
</Text>
</View>
<Link2 size={16} color="#ea580c" strokeWidth={2.5} />
</Pressable>
))
) : (
<EmptyState
title={
invoiceSearch
? "No invoices match your search"
: "No invoices available"
}
/>
)}
</ScrollView>
</PickerModal>
<ActionModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={confirmDelete}
title="Delete Payment"
description="Are you sure you want to permanently delete this payment record? This action cannot be reversed."
confirmText="Delete"
confirmVariant="destructive"
icon={Trash2}
iconColor="#ef4444"
loading={deleting}
/>
{/* Flag Modal */}
<Modal
visible={showFlagModal}
transparent
animationType="fade"
onRequestClose={() => setShowFlagModal(false)}
>
<Pressable
className="flex-1 bg-black/40 justify-end"
onPress={() => setShowFlagModal(false)}
>
<Pressable
onPress={(e) => e.stopPropagation()}
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
style={{ maxHeight: SCREEN_HEIGHT * 0.8 }}
>
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center">
<View className="w-10" />
<Text className="text-foreground font-sans-bold text-[18px]">
Flag Payment
</Text>
<Pressable
onPress={() => setShowFlagModal(false)}
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
>
<X
size={14}
color={isDark ? "#f1f5f9" : "#0f172a"}
strokeWidth={2.5}
/>
</Pressable>
</View>
<ScrollView
className="px-5"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 24 }}
>
<Text className="text-muted-foreground text-[12px] font-sans-medium mb-4">
Mark this payment as suspicious. The reason will be visible to
your team and recorded in the audit log.
</Text>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-2 ml-1">
Reason
</Text>
<View className="gap-2 mb-4">
{(["FAKE", "SCAM", "OTHER"] as const).map((r) => (
<Pressable
key={r}
onPress={() => setFlagReason(r)}
className={`h-11 px-3 border rounded-[6px] flex-row items-center justify-between ${
flagReason === r
? "border-primary bg-primary/10"
: "border-border"
}`}
style={{
backgroundColor:
flagReason === r
? isDark
? "rgba(234,88,12,0.15)"
: "rgba(234,88,12,0.08)"
: c.bg,
}}
>
<Text
className="text-xs font-sans-bold"
style={{ color: c.text }}
>
{r === "FAKE"
? "Fake Payment"
: r === "SCAM"
? "Scam / Phishing"
: "Other"}
</Text>
{flagReason === r && (
<Check size={14} color="#ea580c" strokeWidth={3} />
)}
</Pressable>
))}
</View>
<Text className="font-sans-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1">
Notes (optional)
</Text>
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
height: 100,
textAlignVertical: "top",
paddingTop: 10,
},
]}
placeholder="Add context for your team..."
placeholderTextColor={c.placeholder}
value={flagNotes}
onChangeText={setFlagNotes}
multiline
/>
<Pressable
onPress={handleFlag}
disabled={flagging}
className="h-12 rounded-[6px] bg-red-500 items-center justify-center flex-row gap-2 mt-5"
>
{flagging ? (
<ActivityIndicator color="white" />
) : (
<>
<Flag color="white" size={16} strokeWidth={2.5} />
<Text className="text-white text-[13px] font-sans-bold">
{isFlagged ? "Update Flag" : "Submit Flag"}
</Text>
</>
)}
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
{/* Full screen image viewer */}
<Modal
visible={showImageFullScreen}
transparent
animationType="fade"
onRequestClose={() => setShowImageFullScreen(false)}
>
<Pressable
className="flex-1 bg-black"
onPress={() => setShowImageFullScreen(false)}
>
<View className="flex-1">
{scannedImageUrl && (
<WebView
source={{ uri: scannedImageUrl }}
style={{ flex: 1, backgroundColor: "#000000" }}
originWhitelist={["*"]}
mixedContentMode="always"
scalesPageToFit
/>
)}
</View>
<Pressable
onPress={() => setShowImageFullScreen(false)}
className="absolute top-12 right-5 h-10 w-10 rounded-full bg-black/60 items-center justify-center border border-white/20"
>
<X size={18} color="#ffffff" strokeWidth={2.5} />
</Pressable>
</Pressable>
</Modal>
</ScreenWrapper>
);
}