1755 lines
62 KiB
TypeScript
1755 lines
62 KiB
TypeScript
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>
|
||
);
|
||
}
|